From ceee0512fd0886b44d87eea8a677f61db2a07c3d Mon Sep 17 00:00:00 2001 From: Krzysztof Rosinski Date: Wed, 14 Oct 2026 06:49:02 +0200 Subject: [PATCH] feat!: group trace styles and prepare 0.7.0 --- CHANGELOG.md | 8 +- demo/measurements.ts | 16 ++-- docs/api-review.md | 9 +- docs/api/smithkit.api.md | 29 ++++++- docs/guide/scales.md | 8 +- docs/guide/traces.md | 20 +++-- docs/{migration-next.md => migration-0.7.md} | 40 ++++++++- docs/migrations.md | 4 +- docs/performance.md | 2 +- docs/releases/0.7.0.md | 43 ++++++++++ examples/README.md | 2 +- examples/basic/README.md | 4 +- examples/basic/package.json | 2 +- examples/large-trace/main.ts | 4 +- examples/package.json | 2 +- package-lock.json | 4 +- package.json | 2 +- scripts/benchmark-traces.mjs | 2 +- scripts/benchmarks/trace-workloads.mjs | 2 +- scripts/check-package.mjs | 12 ++- src/Smith.ts | 39 +++++---- src/index.ts | 1 + src/measurements.ts | 27 ++++-- src/rendering/ChartHost.ts | 15 ++++ src/rendering/SvgChartRenderer.ts | 4 +- src/scales/SmithScales.ts | 31 ++++++- src/traces/SmithData.ts | 6 +- src/traces/TraceRenderer.ts | 43 +++++----- tests/browser/lifecycle.spec.ts | 6 +- tests/browser/scales.spec.ts | 66 +++++++++++++++ tests/browser/trace-line-detail.spec.ts | 10 +-- tests/browser/trace-style.spec.ts | 88 +++++++++++++++++++- 32 files changed, 442 insertions(+), 109 deletions(-) rename docs/{migration-next.md => migration-0.7.md} (78%) create mode 100644 docs/releases/0.7.0.md create mode 100644 src/rendering/ChartHost.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba42f3..fe30b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ # Changelog -## Unreleased — next minor +## 0.7.0 — 2026-10-14 -- **Breaking:** group grid/circle styles and appearance overrides by visual element (`line`, `text`, `point`, `focus`, and scale subgroups). Replace flat style keys, preserve nested partial updates, and allow independent Q/VSWR theme widths. See the [migration guide](docs/migration-next.md). +- **Breaking:** group trace style options and snapshots into `line: { width, tolerancePx }` and `point: { radius }`. Add `TraceStyleOptions` for nested partial patches, reject unknown keys, and return detached readonly style snapshots. +- Allow `new SmithScales(target, appearance?)` to configure and mount independent parameter scales in one call, while preserving deferred mounting. +- Refresh the demo and example styling, add syntax highlighting and formatted example source, and streamline CI with shared build artifacts and parallel checks. + +- **Breaking:** group grid/circle styles and appearance overrides by visual element (`line`, `text`, `point`, `focus`, and scale subgroups). Replace flat style keys, preserve nested partial updates, and allow independent Q/VSWR theme widths. See the [migration guide](docs/migration-0.7.md). - Allow `new Smith(target, options?)` to configure and mount a chart with one call, accepting the same CSS selector or HTMLElement as `draw()`. Keep deferred mounting with `new Smith(options)`. ## 0.6.0 — 2026-10-14 diff --git a/demo/measurements.ts b/demo/measurements.ts index 1237b50..7bf64ba 100644 --- a/demo/measurements.ts +++ b/demo/measurements.ts @@ -174,24 +174,24 @@ export class Measurements { }); modeLabel.append(mode); appearance.append(modeLabel); - for (const [key, caption] of [ - ['lineWidth', 'Line width (px)'], - ['pointRadius', 'Point radius (px)'], - ['lineTolerancePx', 'Line tolerance (px, 0 = full)'], + for (const [group, key, caption, current] of [ + ['line', 'width', 'Line width (px)', trace.line.width], + ['point', 'radius', 'Point radius (px)', trace.point.radius], + ['line', 'tolerancePx', 'Line tolerance (px, 0 = full)', trace.line.tolerancePx], ] as const) { const label = document.createElement('label'); label.textContent = caption + ' '; const input = document.createElement('input'); input.type = 'number'; - input.min = key === 'lineTolerancePx' ? '0' : '0.1'; + input.min = key === 'tolerancePx' ? '0' : '0.1'; input.step = 'any'; - input.value = String(trace[key]); + input.value = String(current); input.addEventListener('change', () => { if (!input.value || !input.checkValidity() || !Number.isFinite(Number(input.value))) { - input.value = String(trace[key]); + input.value = String(current); return; } - this.smith.setTraceOptions(trace.id, { [key]: Number(input.value) }); + this.smith.setTraceOptions(trace.id, { [group]: { [key]: Number(input.value) } }); this.changed(); }); label.append(input); diff --git a/docs/api-review.md b/docs/api-review.md index 1333c42..9ae3126 100644 --- a/docs/api-review.md +++ b/docs/api-review.md @@ -1,6 +1,6 @@ # Public API review and compatibility policy -Reviewed against **0.6.0 and the next minor API changes**, including grouped visual styles, optional constructor mounting, compact trace input, line simplification, and Touchstone reflection I/O, +Reviewed against **0.7.0**, including grouped visual styles, optional constructor mounting, compact trace input, line simplification, and Touchstone reflection I/O, including the installed integration examples and the subsequent React/Angular lifecycle verification. Version 0.3.0 used positional constructor arguments, interactive defaults, and an automatic marker. See [migration to 0.4](migration-0.4.md). @@ -112,8 +112,9 @@ arbitrary side effects from application callbacks. hides geometry, cancels queued readings, and clears the readout. Enabling waits for a new pointer movement. `cursorReading` retains the last position. -`SmithScales` is a separate component whose constructor takes only -`SmithAppearance`. It does not inherit chart options or listen to chart events +`SmithScales` is a separate component configured with `SmithAppearance`. +Like `Smith`, it optionally accepts a CSS selector or `HTMLElement` first for +immediate mounting. It does not inherit chart options or listen to chart events implicitly. Consumers select the source reading with `update(Complex | null)`. Keeping this constructor focused avoids grid/interaction options that do not apply. @@ -310,7 +311,7 @@ block 1.0; future additions must respect the public boundary above. ## Trace display accuracy -`TraceOptions.lineTolerancePx` is a per-trace display setting, shared by `addTrace()` +`TraceOptions.line.tolerancePx` is a per-trace display setting, shared by `addTrace()` and `setTraceOptions()`, and reported in `TraceInfo`. Its default is zero, retaining full line geometry. Finite non-negative CSS pixel values bound additional geometric deviation; invalid values throw `RangeError` before mutation. It never changes diff --git a/docs/api/smithkit.api.md b/docs/api/smithkit.api.md index c585923..6359e55 100644 --- a/docs/api/smithkit.api.md +++ b/docs/api/smithkit.api.md @@ -607,6 +607,7 @@ export interface SmithReading { // @public export class SmithScales { constructor(appearance?: SmithAppearance); + constructor(target: string | HTMLElement, appearance?: SmithAppearance); // (undocumented) destroy(): void; // (undocumented) @@ -764,10 +765,14 @@ export interface TraceInfo extends Readonly { // (undocumented) readonly id: string; // (undocumented) + readonly line: Readonly; + // (undocumented) readonly markers: readonly MarkerInfo[]; // (undocumented) readonly name: string; // (undocumented) + readonly point: Readonly; + // (undocumented) readonly sampleCount: number; // (undocumented) readonly visible: boolean; @@ -777,7 +782,7 @@ export interface TraceInfo extends Readonly { export type TraceInput = TraceSamples | readonly TraceTuple[] | Float64Array; // @public (undocumented) -export interface TraceOptions extends Partial { +export interface TraceOptions extends TraceStyleOptions { color?: string; // (undocumented) name?: string; @@ -801,11 +806,27 @@ export type TraceSamples = readonly TraceSample[]; // @public (undocumented) export interface TraceStyle { - lineTolerancePx: number; - lineWidth: number; + // (undocumented) + line: { + width: number; + tolerancePx: number; + }; // (undocumented) mode: TraceRenderMode; - pointRadius: number; + // (undocumented) + point: { + radius: number; + }; +} + +// @public +export interface TraceStyleOptions { + // (undocumented) + line?: Partial; + // (undocumented) + mode?: TraceRenderMode; + // (undocumented) + point?: Partial; } // @public diff --git a/docs/guide/scales.md b/docs/guide/scales.md index 7ab70a4..9f6bfe4 100644 --- a/docs/guide/scales.md +++ b/docs/guide/scales.md @@ -14,8 +14,7 @@ Mount `SmithScales` independently so its twelve axes stay outside chart zoom: ```ts import { SmithScales, SmithEventType } from 'smithkit'; -const scales = new SmithScales(); -scales.draw('#smith-scales'); // Provide a separate mounted container. +const scales = new SmithScales('#smith-scales', { theme: 'dark' }); const stopScales = chart.onEvent((event) => { if (event.type === SmithEventType.Cursor || event.type === SmithEventType.Marker) { const gamma = event.data?.reflectionCoefficient ?? null; @@ -28,7 +27,10 @@ const stopScales = chart.onEvent((event) => { ``` `update(Complex | null)` synchronizes indicators, values, tooltips, and accessible -labels. Null, non-finite, or active-load inputs hide indicators. `draw` accepts a +labels. Null, non-finite, or active-load inputs hide indicators. +The constructor optionally accepts a CSS selector or `HTMLElement`, followed by +`SmithAppearance`. For deferred mounting, use `new SmithScales(appearance)` then +`draw(target)`. `draw` accepts a selector or `HTMLElement`; `destroy` is idempotent. Scales adapt to container width. Labels follow the complete Smith chart terminology: **Reflection loss**, diff --git a/docs/guide/traces.md b/docs/guide/traces.md index d36e59d..bdbc107 100644 --- a/docs/guide/traces.md +++ b/docs/guide/traces.md @@ -67,7 +67,7 @@ Invalid replacements leave data, markers, and rendering unchanged. For more than 5,000 samples, point rendering groups overlapping points into screen cells and skips offscreen points; markers still select from the full data. Lines retain every sample by default; opt into pixel-based simplification with -`lineTolerancePx` as described under [trace appearance](#trace-appearance). +`line.tolerancePx` as described under [trace appearance](#trace-appearance). Sorted ascending frequencies accelerate frequency selection; unsorted inputs remain supported without reordering. There is no interpolation or reduction of the stored measurements. @@ -145,10 +145,20 @@ after destruction throw; trace/marker lookups return empty or missing results. ## Trace appearance -Pass `mode: 'points' | 'line' | 'both'`, `lineWidth`, `pointRadius`, and `lineTolerancePx` to +Pass `mode: 'points' | 'line' | 'both'`, `line.width`, `point.radius`, and `line.tolerancePx` to `addTrace` or `setTraceOptions`. Defaults are points, 2 px line width, and 2 px point radius. Both sizes are CSS pixels and remain constant under chart zoom and container resize. Widths and radii must be positive and finite. -`getTraces()` includes the current style. +`getTraces()` includes the current style in detached, readonly nested objects. +Updates merge individual properties, preserving omitted line and point settings. + +```ts +chart.setTraceOptions(traceId, { + mode: 'both', + line: { width: 3, tolerancePx: 0.5 }, + point: { radius: 4 }, +}); +chart.setTraceOptions(traceId, { line: { width: 2 } }); // Keeps tolerance and radius. +``` By default, lines connect all samples in input order, without smoothing or frequency sorting. A line-only trace creates one SVG path and no sample circles, suitable for dense @@ -160,11 +170,11 @@ snapping, readouts, and comparisons. Styles do not change marker identity or layer ordering. Hidden traces retain their style after sample replacement. The demo exposes the same controls per trace. -For dense lines, opt into `lineTolerancePx` (finite, non-negative; default `0` disables +For dense lines, opt into `line.tolerancePx` (finite, non-negative; default `0` disables simplification): ```ts -chart.setTraceOptions(traceId, { mode: 'line', lineTolerancePx: 0.5 }); +chart.setTraceOptions(traceId, { mode: 'line', line: { tolerancePx: 0.5 } }); ``` The tolerance bounds additional geometric deviation in **CSS pixels**, accounting for diff --git a/docs/migration-next.md b/docs/migration-0.7.md similarity index 78% rename from docs/migration-next.md rename to docs/migration-0.7.md index 9ee9b3c..5ed13e0 100644 --- a/docs/migration-next.md +++ b/docs/migration-0.7.md @@ -1,6 +1,6 @@ -# Migration from 0.6.x to the next minor +# Migration from 0.6.x to 0.7.0 -These changes are unreleased and require a minor version during 0.x development. +Version 0.7.0 changes the visual configuration schema. The old flat visual style keys are removed; they are not compatibility aliases. Default appearance, interaction, grid detail, and trace behavior remain unchanged. @@ -30,6 +30,32 @@ Use `GridStyleOptions` / `CircleStyleOptions` for partial patches instead of properties; for example, changing `text.color` preserves the font and line style. Unknown keys and invalid nested values are rejected before changing the chart. +## Trace styles + +| Previous trace key | New trace key | +| ------------------ | ------------------ | +| `lineWidth` | `line.width` | +| `lineTolerancePx` | `line.tolerancePx` | +| `pointRadius` | `point.radius` | + +The new shape applies to `addTrace()`, `setTraceOptions()`, and `getTraces()`: + +```ts +const traceId = chart.addTrace(samples, { + mode: 'both', + line: { width: 2, tolerancePx: 0.5 }, + point: { radius: 3 }, +}); +chart.setTraceOptions(traceId, { line: { width: 4 } }); +// The tolerance and point radius are preserved. +console.log(chart.getTraces()[0].line.width); +``` + +Use `TraceStyleOptions` for partial style patches rather than `Partial`. +Snapshots have readonly, detached `line` and `point` objects. Unknown keys and +invalid nested values throw before changing the trace. Defaults remain points, +2 px line width, 2 px point radius, and no line simplification. + ## Theme overrides The `appearance: { theme, overrides }` wrapper and replacement semantics stay the @@ -70,3 +96,13 @@ The first argument is a CSS selector or an `HTMLElement`. It is optional: existi `new Smith(options)` followed by `draw(target)` still works. Data and markers are added through the existing methods. The target is separate from configuration so `setOptions()` keeps the chart in its current container. + +`SmithScales` supports the same optional mounting argument, followed by its +independent appearance configuration: + +```ts +const scales = new SmithScales('#scales', { theme: 'dark' }); +``` + +`new SmithScales(appearance)` followed by `draw(target)` remains available. +Scales do not inherit chart settings or subscribe to chart events automatically. diff --git a/docs/migrations.md b/docs/migrations.md index ebcf071..b036398 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -5,10 +5,10 @@ new integration, start with the [README](../README.md) and the [API documentation](https://rosek86.github.io/smithkit/api/). When skipping versions, apply the relevant migrations in version order. -## 0.6.x to the next minor (unreleased) +## 0.6.x to 0.7.0 Visual styles are grouped by element and chart construction can mount directly -into a container. Follow the [next minor migration guide](migration-next.md). +into a container. Follow the [0.7 migration guide](migration-0.7.md). ## 0.4.x to 0.5.0 diff --git a/docs/performance.md b/docs/performance.md index cfc25ef..0bdf987 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -69,7 +69,7 @@ the zoom handler. Input shape alone is not a reliable predictor of rendering cos ## Optional line simplification — 2026-10-09 -`lineTolerancePx` defaults to 0 (full geometry). With a positive tolerance, nested +`line.tolerancePx` defaults to 0 (full geometry). With a positive tolerance, nested radial-distance levels cache ordered sample indices. Each level accounts for the accumulated error of earlier levels; selection uses the chart's CSS scale and zoom. Panning and zooming within a level do not rewrite the path. Crossing a level rewrites diff --git a/docs/releases/0.7.0.md b/docs/releases/0.7.0.md new file mode 100644 index 0000000..b02ce66 --- /dev/null +++ b/docs/releases/0.7.0.md @@ -0,0 +1,43 @@ +# Smithkit 0.7.0 + +This release groups visual configuration by element and adds optional constructor +mounting for charts and independent parameter scales. + +## Configuration changes + +- Trace styles use `line: { width, tolerancePx }` and `point: { radius }`. + `setTraceOptions()` merges individual nested properties. `getTraces()` returns + detached, readonly nested style snapshots. +- Grid and constant-circle styles use `line` and `text` groups. Appearance overrides + group grid, scales, cursor, marker, and trace settings by visual element. +- Invalid or unknown style options are rejected before changing the chart. +- Chart defaults remain unchanged: standard impedance grid, labels visible, + peripheral scales, zoom and cursor disabled, and no implicit markers. + +These are breaking changes from 0.6.x. Update flat style keys using the +[migration guide](https://github.com/rosek86/smithkit/blob/v0.7.0/docs/migration-0.7.md). +No compatibility aliases are retained. + +## Mounting and examples + +```ts +const chart = new Smith('#chart', { appearance: { theme: 'dark' } }); +const scales = new SmithScales('#scales', { theme: 'dark' }); +const traceId = chart.addTrace(samples, { + mode: 'both', + line: { width: 2, tolerancePx: 0.5 }, + point: { radius: 3 }, +}); +``` + +Both constructors accept an `HTMLElement` instead of a selector. Deferred mounting +with `draw()` remains available. Scales stay independent of chart configuration +and events. + +The demo and integration examples share updated styling, formatted source, and +syntax highlighting. The [API reference](https://rosek86.github.io/smithkit/api/) +and installed-package checks cover the new option shapes and constructor overloads. + +```sh +npm install smithkit@0.7.0 +``` diff --git a/examples/README.md b/examples/README.md index 750e690..fd9d97c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,7 +27,7 @@ npm run dev ``` To test local library changes, run `npm pack` from the repository root, then -`npm install --no-save --package-lock=false ../smithkit-0.6.0.tgz` in `examples`. +`npm install --no-save --package-lock=false ../smithkit-0.7.0.tgz` in `examples`. Use the archive's absolute path when running a copied gallery. `npm run build` creates the complete standalone gallery in `dist/`. It imports the diff --git a/examples/basic/README.md b/examples/basic/README.md index 4dbe7cc..5d1e3d2 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -4,7 +4,7 @@ A standalone application importing only the installed `smithkit` package. It has no source aliases, relative imports into the library, or dependency on demo code. Use Node.js 24 and npm. -Install the declared `smithkit@^0.5.0` dependency and start the example: +Install the declared `smithkit@^0.7.0` dependency and start the example: ```sh npm install @@ -12,7 +12,7 @@ npm run dev ``` To test local library changes, run `npm pack` from the repository root, then -`npm install --no-save --package-lock=false ../../smithkit-0.5.0.tgz` here. +`npm install --no-save --package-lock=false ../../smithkit-0.7.0.tgz` here. The example can be copied into a separate project; use the archive's absolute path when testing a copied example against the local checkout. diff --git a/examples/basic/package.json b/examples/basic/package.json index 2820cde..00c8fb2 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -8,7 +8,7 @@ "preview": "vite preview --host 127.0.0.1" }, "dependencies": { - "smithkit": "^0.6.0" + "smithkit": "^0.7.0" }, "devDependencies": { "shiki": "4.5.0", diff --git a/examples/large-trace/main.ts b/examples/large-trace/main.ts index ae094f9..ef78d83 100644 --- a/examples/large-trace/main.ts +++ b/examples/large-trace/main.ts @@ -21,7 +21,7 @@ chart.draw('#chart'); const traceId = chart.addTrace(createSamples(count), { name: 'Synthetic sweep', mode: 'line', - lineTolerancePx: 0.5, + line: { tolerancePx: 0.5 }, }); const markerId = chart.addMarker(traceId)!; const slider = document.querySelector('#sample')!; @@ -47,7 +47,7 @@ renderReading(); const detail = document.querySelector('#line-detail')!; detail.addEventListener('change', () => { - chart.setTraceOptions(traceId, { lineTolerancePx: Number(detail.value) }); + chart.setTraceOptions(traceId, { line: { tolerancePx: Number(detail.value) } }); }); const sampleCount = document.querySelector('#sample-count')!; diff --git a/examples/package.json b/examples/package.json index 6b6fbf6..aa2bfc5 100644 --- a/examples/package.json +++ b/examples/package.json @@ -8,7 +8,7 @@ "preview": "vite preview --host 127.0.0.1" }, "dependencies": { - "smithkit": "^0.6.0", + "smithkit": "^0.7.0", "react": "19.3.0", "react-dom": "19.3.0", "@angular/core": "22.2.2", diff --git a/package-lock.json b/package-lock.json index 9973a53..0dce6c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "smithkit", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smithkit", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "@types/d3": "^7.4.3", diff --git a/package.json b/package.json index 7531bfc..b8b908c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smithkit", - "version": "0.6.0", + "version": "0.7.0", "description": "Framework-independent, interactive Smith chart library for JavaScript and TypeScript", "type": "module", "scripts": { diff --git a/scripts/benchmark-traces.mjs b/scripts/benchmark-traces.mjs index e644552..96cf043 100644 --- a/scripts/benchmark-traces.mjs +++ b/scripts/benchmark-traces.mjs @@ -61,7 +61,7 @@ try { const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); let id; const addMs = measure(() => { - id = chart.addTrace(input, { mode, lineTolerancePx }); + id = chart.addTrace(input, { mode, line: { tolerancePx: lineTolerancePx } }); }); const marker = chart.addMarker(id); const updateMs = measure(() => chart.updateTrace(id, input)); diff --git a/scripts/benchmarks/trace-workloads.mjs b/scripts/benchmarks/trace-workloads.mjs index ae110ae..8f82a69 100644 --- a/scripts/benchmarks/trace-workloads.mjs +++ b/scripts/benchmarks/trace-workloads.mjs @@ -48,7 +48,7 @@ function create(config) { const ids = []; const add = timed(() => inputs.forEach((input) => - ids.push(chart.addTrace(input, { mode: 'line', lineTolerancePx: config.tolerance })), + ids.push(chart.addTrace(input, { mode: 'line', line: { tolerancePx: config.tolerance } })), ), ); const marker = chart.addMarker(ids[0]); diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 9f85c4c..634cedd 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -94,6 +94,8 @@ chart.setOptions({ interaction: { cursor: false }, grid: { layers: { resistance: chart.setOptions({ cursorEnabled: true }); new Smith(); new Smith({}); +new SmithScales('#scales'); +new SmithScales(document.createElement('div'), { theme: 'dark' }); new Smith('#chart'); new Smith(document.createElement('div'), configuration); new Smith('#chart', { grid: { style: { text: { color: 'red' } } }, appearance: { overrides: { grid: { text: { fontSize: 10 } } } } }); @@ -123,7 +125,11 @@ new Smith({ circles: {q: { detail: 'basic' }} }); chart.setGridDetail('sparse'); // @ts-expect-error Circle values are numeric. chart.layers.vswr.setValues(['2']); -const options: TraceOptions = { name: 'Consumer', color: '#123456', visible: true, mode: 'both', lineWidth: 2, pointRadius: 3, lineTolerancePx: 0.5 }; +// @ts-expect-error Flat trace style keys were removed in 0.7. +chart.setTraceOptions('trace', { lineWidth: 2 }); +// @ts-expect-error Trace snapshots have readonly nested style fields. +chart.getTraces()[0].point.radius = 2; +const options: TraceOptions = { name: 'Consumer', color: '#123456', visible: true, mode: 'both', line: { width: 2, tolerancePx: 0.5 }, point: { radius: 3 } }; const traceId: string = chart.addTrace(samples, options); import type { TraceInput, TraceTuple } from 'smithkit'; const tuples: readonly TraceTuple[] = [[1e9, 0.1, -0.2]]; @@ -141,11 +147,11 @@ chart.addTrace([[1e9, 0.1]]); chart.addTrace([{ frequencyHz: 1, reflectionCoefficient: [0, 0] }, [1, 0, 0]]); // @ts-expect-error Only Float64Array is a supported packed numeric buffer. chart.addTrace(new Float32Array([1e9, 0.1, 0.2])); -chart.setTraceOptions(traceId, { lineTolerancePx: 0 }); +chart.setTraceOptions(traceId, { line: { tolerancePx: 0 } }); chart.toSvg({ lineDetail: 'full' }); chart.toPng({ lineDetail: 'view' }); // @ts-expect-error Tolerance is a numeric CSS pixel value. -chart.setTraceOptions(traceId, { lineTolerancePx: 'auto' }); +chart.setTraceOptions(traceId, { line: { tolerancePx: 'auto' } }); // @ts-expect-error Only full and view line detail are supported. chart.toSvg({ lineDetail: 'fast' }); const traces: TraceInfo[] = chart.getTraces(); diff --git a/src/Smith.ts b/src/Smith.ts index fba801b..b618c7b 100644 --- a/src/Smith.ts +++ b/src/Smith.ts @@ -1,4 +1,5 @@ import { OptionsValidation } from './OptionsValidation.js'; +import { ChartHost } from './rendering/ChartHost.js'; import type { SmithOptions } from './options.js'; import { MarkerLegend } from './svg/MarkerLegend.js'; import { ImageExporter } from './svg/ImageExporter.js'; @@ -71,7 +72,7 @@ export class Smith { configuration?: SmithOptions, ) { const target = - typeof targetOrOptions === 'string' || Smith.isHost(targetOrOptions) + typeof targetOrOptions === 'string' || ChartHost.isElement(targetOrOptions) ? targetOrOptions : undefined; if (target === undefined && configuration !== undefined) { @@ -111,18 +112,6 @@ export class Smith { } } - private static isHost(value: unknown): value is HTMLElement { - if (!value || typeof value !== 'object') { - return false; - } - if (typeof HTMLElement !== 'undefined' && value instanceof HTMLElement) { - return true; - } - // Accept elements from another window without requiring that window's globals. - const elementType = (value as HTMLElement).ownerDocument?.defaultView?.HTMLElement; - return elementType !== undefined && value instanceof elementType; - } - /** Apply a validated patch. Omitted fields retain their current settings. */ public setOptions(options: SmithOptions): void { this.assertAlive(); @@ -379,17 +368,33 @@ export class Smith { } private validateTraceOptions(options: TraceOptions): void { + OptionsValidation.object(options, 'Trace', [ + 'mode', + 'line', + 'point', + 'name', + 'color', + 'visible', + ]); + if (options.line !== undefined) { + OptionsValidation.object(options.line, 'Trace line', ['width', 'tolerancePx']); + } + if (options.point !== undefined) { + OptionsValidation.object(options.point, 'Trace point', ['radius']); + } if (options.mode !== undefined && !['points', 'line', 'both'].includes(options.mode)) { throw new TypeError('Trace mode must be points, line, or both.'); } if ( - options.lineTolerancePx !== undefined && - (!Number.isFinite(options.lineTolerancePx) || options.lineTolerancePx < 0) + options.line?.tolerancePx !== undefined && + (!Number.isFinite(options.line.tolerancePx) || options.line.tolerancePx < 0) ) { throw new RangeError('Line tolerance must be finite and non-negative.'); } - for (const key of ['lineWidth', 'pointRadius'] as const) { - const value = options[key]; + for (const [key, value] of [ + ['line.width', options.line?.width], + ['point.radius', options.point?.radius], + ] as const) { if (value !== undefined && (!Number.isFinite(value) || value <= 0)) { throw new RangeError(`${key} must be positive and finite.`); } diff --git a/src/index.ts b/src/index.ts index d7bac9f..a47b941 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export type { TraceUpdateOptions, MarkerSelectionStrategy, TraceStyle, + TraceStyleOptions, TraceRenderMode, TraceInfo, MarkerInfo, diff --git a/src/measurements.ts b/src/measurements.ts index 2c2acaf..3c702c2 100644 --- a/src/measurements.ts +++ b/src/measurements.ts @@ -5,15 +5,26 @@ export type TraceRenderMode = 'points' | 'line' | 'both'; export interface TraceStyle { mode: TraceRenderMode; - /** Non-scaling SVG stroke width in CSS pixels. Default: 2. */ - lineWidth: number; - /** Point radius in CSS pixels; remains constant under chart zoom and container resize. Default: 2. */ - pointRadius: number; - /** Maximum additional line simplification error in CSS pixels. Zero disables it (default). */ - lineTolerancePx: number; + line: { + /** Non-scaling SVG stroke width in CSS pixels. Default: 2. */ + width: number; + /** Maximum additional line simplification error in CSS pixels. Zero disables it (default). */ + tolerancePx: number; + }; + point: { + /** Radius in CSS pixels; constant under chart zoom and container resize. Default: 2. */ + radius: number; + }; } -export interface TraceOptions extends Partial { +/** A partial style patch; omitted nested properties retain their current values. */ +export interface TraceStyleOptions { + mode?: TraceRenderMode; + line?: Partial; + point?: Partial; +} + +export interface TraceOptions extends TraceStyleOptions { name?: string; /** A solid CSS color, such as '#2563eb' or 'orange'. */ color?: string; @@ -36,6 +47,8 @@ export interface MarkerInfo { } export interface TraceInfo extends Readonly { + readonly line: Readonly; + readonly point: Readonly; readonly id: string; readonly name: string; readonly color: string; diff --git a/src/rendering/ChartHost.ts b/src/rendering/ChartHost.ts new file mode 100644 index 0000000..a680dc7 --- /dev/null +++ b/src/rendering/ChartHost.ts @@ -0,0 +1,15 @@ +/** Recognize HTML hosts across windows without accessing browser globals at import time. */ +export class ChartHost { + private constructor() {} + + public static isElement(value: unknown): value is HTMLElement { + if (!value || typeof value !== 'object') { + return false; + } + if (typeof HTMLElement !== 'undefined' && value instanceof HTMLElement) { + return true; + } + const elementType = (value as HTMLElement).ownerDocument?.defaultView?.HTMLElement; + return elementType !== undefined && value instanceof elementType; + } +} diff --git a/src/rendering/SvgChartRenderer.ts b/src/rendering/SvgChartRenderer.ts index 1ac7829..4d9929c 100644 --- a/src/rendering/SvgChartRenderer.ts +++ b/src/rendering/SvgChartRenderer.ts @@ -36,7 +36,7 @@ import { SmithPeripheralScales } from '../scales/SmithPeripheralScales.js'; import { GridLayerControl } from '../grid/GridLayerControl.js'; import { CircleLayerControl } from '../grid/CircleLayerControl.js'; import type { ChartLayers, PeripheralScales } from '../layers.js'; -import type { TraceStyle } from '../measurements.js'; +import type { TraceStyleOptions } from '../measurements.js'; interface Scalers { default: SmithScaler; @@ -463,7 +463,7 @@ export class SvgChartRenderer { public createTrace( values: TraceInput, color: string, - style: Partial, + style: TraceStyleOptions, onMarkerDrag: (marker: SmithMarker, dragging: boolean) => void, ): SmithData { this.assertAlive(); diff --git a/src/scales/SmithScales.ts b/src/scales/SmithScales.ts index da51116..bf41794 100644 --- a/src/scales/SmithScales.ts +++ b/src/scales/SmithScales.ts @@ -1,4 +1,5 @@ import { ImageExporter } from '../svg/ImageExporter.js'; +import { ChartHost } from '../rendering/ChartHost.js'; import type { ScaleImageExportOptions } from '../svg/export.js'; import { Theme } from '../appearance/Theme.js'; import { SvgTheme } from '../appearance/SvgTheme.js'; @@ -32,7 +33,27 @@ export class SmithScales { .style('width', '100%') .style('height', 'auto'); - public constructor(appearance: SmithAppearance = {}) { + /** Configure independent scales for later mounting with draw(). */ + public constructor(appearance?: SmithAppearance); + /** Configure and mount immediately. String targets are CSS selectors, as in draw(). */ + public constructor(target: string | HTMLElement, appearance?: SmithAppearance); + public constructor( + targetOrAppearance: string | HTMLElement | SmithAppearance = {}, + configuration?: SmithAppearance, + ) { + const target = + typeof targetOrAppearance === 'string' || ChartHost.isElement(targetOrAppearance) + ? targetOrAppearance + : undefined; + if (target === undefined && configuration !== undefined) { + throw new TypeError('A container is required before the second appearance argument.'); + } + const appearance = + target === undefined + ? (targetOrAppearance as SmithAppearance) + : configuration === undefined + ? {} + : configuration; this.setAppearance(appearance); const labels = this.axes.append('g').attr('data-role', 'labels').attr('pointer-events', 'none'); labels @@ -105,6 +126,14 @@ export class SmithScales { .attr('stroke-width', 1); labels.raise(); this.axes.append('title').text((scale) => scale.description ?? scale.title); + if (target !== undefined) { + try { + this.draw(target); + } catch (error) { + this.destroy(); + throw error; + } + } } /** Replace the preset and overrides without changing readings or layout. */ diff --git a/src/traces/SmithData.ts b/src/traces/SmithData.ts index 4e323ad..a2b87fe 100644 --- a/src/traces/SmithData.ts +++ b/src/traces/SmithData.ts @@ -1,5 +1,5 @@ import { SmithGroup } from '../svg/SmithGroup.js'; -import type { TraceStyle, MarkerSelectionStrategy } from '../measurements.js'; +import type { TraceStyle, TraceStyleOptions, MarkerSelectionStrategy } from '../measurements.js'; import { SmithMarker } from './SmithMarker.js'; import { SmithScaler } from '../svg/SmithScaler.js'; import type { TraceInput, TraceSample } from '../samples.js'; @@ -28,7 +28,7 @@ export class SmithData { private scaler: SmithScaler, private markerDragHandler?: (marker: SmithMarker, dragging: boolean) => void, private markerContainer: SmithGroup = container, - style: Partial = {}, + style: TraceStyleOptions = {}, ) { this.model = new TraceModel(data); this.renderer = new TraceRenderer( @@ -148,7 +148,7 @@ export class SmithData { return this.renderer.Style; } - public setStyle(options: Partial): void { + public setStyle(options: TraceStyleOptions): void { this.renderer.setStyle(options); } diff --git a/src/traces/TraceRenderer.ts b/src/traces/TraceRenderer.ts index 305e678..92e7195 100644 --- a/src/traces/TraceRenderer.ts +++ b/src/traces/TraceRenderer.ts @@ -1,7 +1,7 @@ import { path, pathRound } from 'd3'; import { SmithGroup } from '../svg/SmithGroup.js'; import { SmithScaler } from '../svg/SmithScaler.js'; -import type { TraceStyle } from '../measurements.js'; +import type { TraceStyle, TraceStyleOptions } from '../measurements.js'; import { TraceLineDetail } from './TraceLineDetail.js'; import { TraceBuffer } from './TraceBuffer.js'; @@ -26,13 +26,12 @@ export class TraceRenderer { private transform: TraceTransform, container: SmithGroup, private scaler: SmithScaler, - style: Partial = {}, + style: TraceStyleOptions = {}, ) { this.style = { mode: style.mode ?? 'points', - lineWidth: style.lineWidth ?? 2, - pointRadius: style.pointRadius ?? 2, - lineTolerancePx: style.lineTolerancePx ?? 0, + line: { width: style.line?.width ?? 2, tolerancePx: style.line?.tolerancePx ?? 0 }, + point: { radius: style.point?.radius ?? 2 }, }; this.group = this.drawTrace(data).attr('pointer-events', 'none'); container.append(this.group); @@ -53,7 +52,7 @@ export class TraceRenderer { .attr('d', this.linePath(this.lineIndices)) .attr('fill', 'none') .attr('stroke', this.color) - .attr('stroke-width', this.style.lineWidth) + .attr('stroke-width', this.style.line.width) .attr('vector-effect', 'non-scaling-stroke') .attr('stroke-linejoin', 'round') .attr('stroke-linecap', 'round'); @@ -65,7 +64,7 @@ export class TraceRenderer { } private selectLineIndices(): Uint32Array | undefined { - if (this.style.lineTolerancePx === 0) { + if (this.style.line.tolerancePx === 0) { return; } this.lineDetail ??= new TraceLineDetail(this.data); @@ -76,7 +75,7 @@ export class TraceRenderer { ) * this.transform.k * this.viewportScale; - return this.lineDetail.select(this.style.lineTolerancePx / unitsToPixels); + return this.lineDetail.select(this.style.line.tolerancePx / unitsToPixels); } private linePath(indices?: Uint32Array): string { @@ -123,9 +122,9 @@ export class TraceRenderer { private renderPoints(group: SmithGroup, data: TraceBuffer): void { const visible: number[] = []; const cells = new Set(); - const cellSize = Math.max(0.5, this.style.pointRadius) / this.viewportScale; + const cellSize = Math.max(0.5, this.style.point.radius) / this.viewportScale; const extent = this.scaler.x(1); - const margin = this.style.pointRadius / this.viewportScale; + const margin = this.style.point.radius / this.viewportScale; const firstCell = Math.floor(-margin / cellSize); const columns = Math.floor((extent + margin) / cellSize) - firstCell + 1; for (let i = 0; i < data.length; i++) { @@ -149,7 +148,7 @@ export class TraceRenderer { .join('circle') .attr('cx', (index) => this.scaler.x(data.real(index))) .attr('cy', (index) => this.scaler.y(data.imaginary(index))) - .attr('r', this.style.pointRadius / (this.transform.k * this.viewportScale)); + .attr('r', this.style.point.radius / (this.transform.k * this.viewportScale)); } public setViewportScale(scale: number): void { @@ -176,7 +175,7 @@ export class TraceRenderer { } else { this.group.Element.selectAll('circle').attr( 'r', - this.style.pointRadius / (k * this.viewportScale), + this.style.point.radius / (k * this.viewportScale), ); } } @@ -188,21 +187,23 @@ export class TraceRenderer { } public get Style(): TraceStyle { - return { ...this.style }; + return { ...this.style, line: { ...this.style.line }, point: { ...this.style.point } }; } - public setStyle(options: Partial): void { + public setStyle(options: TraceStyleOptions): void { const next = { mode: options.mode ?? this.style.mode, - lineWidth: options.lineWidth ?? this.style.lineWidth, - pointRadius: options.pointRadius ?? this.style.pointRadius, - lineTolerancePx: options.lineTolerancePx ?? this.style.lineTolerancePx, + line: { + width: options.line?.width ?? this.style.line.width, + tolerancePx: options.line?.tolerancePx ?? this.style.line.tolerancePx, + }, + point: { radius: options.point?.radius ?? this.style.point.radius }, }; if ( next.mode === this.style.mode && - next.lineWidth === this.style.lineWidth && - next.pointRadius === this.style.pointRadius && - next.lineTolerancePx === this.style.lineTolerancePx + next.line.width === this.style.line.width && + next.point.radius === this.style.point.radius && + next.line.tolerancePx === this.style.line.tolerancePx ) { return; } @@ -211,7 +212,7 @@ export class TraceRenderer { if (modeChanged) { this.redraw(); } else { - this.group.Element.select('.trace-line').attr('stroke-width', next.lineWidth); + this.group.Element.select('.trace-line').attr('stroke-width', next.line.width); this.zoomDataPoints(); } } diff --git a/tests/browser/lifecycle.spec.ts b/tests/browser/lifecycle.spec.ts index d8991fd..ae7ed3e 100644 --- a/tests/browser/lifecycle.spec.ts +++ b/tests/browser/lifecycle.spec.ts @@ -353,7 +353,7 @@ test('traces share the current view when created before mounting or after zoom a const samples: import('../../src').TraceSamples = [ { frequencyHz: 1e9, reflectionCoefficient: [0.25, 0.1] }, ]; - chart.addMarker(chart.addTrace(samples, { pointRadius: 3 })); + chart.addMarker(chart.addTrace(samples, { point: { radius: 3 } })); document.getElementById('first')!.style.width = '320px'; chart.draw('#first'); const svg = document.querySelector('#first svg')!; @@ -369,7 +369,7 @@ test('traces share the current view when created before mounting or after zoom a }), ); const zoomed = view.getAttribute('transform') !== initial; - chart.addMarker(chart.addTrace(samples, { pointRadius: 3 })); + chart.addMarker(chart.addTrace(samples, { point: { radius: 3 } })); const sizes = () => [...svg.querySelectorAll('[data-role=samples] circle, .marker-hit-area')].map((node) => { // Firefox expands/quantizes screen bounds, even for invisible SVG circles. @@ -387,7 +387,7 @@ test('traces share the current view when created before mounting or after zoom a const sameView = view.getAttribute('transform'); chart.clearTraces(); const emptied = svg.querySelectorAll('[data-role=samples], [data-role=marker]').length; - chart.addMarker(chart.addTrace(samples, { pointRadius: 3 })); + chart.addMarker(chart.addTrace(samples, { point: { radius: 3 } })); const afterClear = sizes(); const viewPreserved = view.getAttribute('transform') === sameView; chart.resetView(); diff --git a/tests/browser/scales.spec.ts b/tests/browser/scales.spec.ts index 6a13166..b3b4715 100644 --- a/tests/browser/scales.spec.ts +++ b/tests/browser/scales.spec.ts @@ -1,6 +1,72 @@ import { expect, test } from '@playwright/test'; import { loadLibrary } from './library'; +test('scales mount from constructor selectors or elements and retain deferred mounting', async ({ + page, +}) => { + await page.setContent('
'); + await loadLibrary(page); + const result = await page.evaluate(() => { + const { SmithScales, Complex } = window.SmithTest; + const host = document.querySelector('#scales')!; + const first = new SmithScales('#scales', { theme: 'dark' }); + first.update(Complex.from(0.5, 0)); + const count = host.querySelectorAll('svg').length; + const color = getComputedStyle(host.querySelector('text')!).fill; + const exported = first.toSvg().includes(' { await page.goto('./'); const rings = page.locator('#smith [data-layer=peripheral-scales]'); diff --git a/tests/browser/trace-line-detail.spec.ts b/tests/browser/trace-line-detail.spec.ts index 69b4bbc..3480211 100644 --- a/tests/browser/trace-line-detail.spec.ts +++ b/tests/browser/trace-line-detail.spec.ts @@ -20,7 +20,7 @@ test('line detail adapts to zoom, keeps full marker data and exports full or vie const path = () => document.querySelector('.trace-line')!.getAttribute('d')!; const vertices = (d: string) => d.match(/[ML]/g)!.length; const full = path(); - chart.setTraceOptions(id, { lineTolerancePx: 1 }); + chart.setTraceOptions(id, { line: { tolerancePx: 1 } }); const simplified = path(); const marker = chart.addMarker(id)!; chart.setMarkerSample(marker, 8765); @@ -92,7 +92,7 @@ test('resize, mode changes and renormalization refresh detail; invalid options a { length: 10_000 }, (_, i) => [i, 0.8 * Math.cos(i / 2000), 0.8 * Math.sin(i / 2000)] as const, ); - const id = chart.addTrace(samples, { mode: 'line', lineTolerancePx: 0.5 }); + const id = chart.addTrace(samples, { mode: 'line', line: { tolerancePx: 0.5 } }); const path = () => document.querySelector('.trace-line')!.getAttribute('d')!; const vertices = () => path().match(/[ML]/g)!.length; const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); @@ -109,7 +109,7 @@ test('resize, mode changes and renormalization refresh detail; invalid options a let rejected = 0; for (const lineTolerancePx of [-1, NaN, Infinity]) { try { - chart.setTraceOptions(id, { lineTolerancePx, mode: 'points' }); + chart.setTraceOptions(id, { line: { tolerancePx: lineTolerancePx }, mode: 'points' }); } catch (error) { if (error instanceof RangeError) { rejected++; @@ -137,9 +137,9 @@ test('resize, mode changes and renormalization refresh detail; invalid options a chart.renormalize(75); const changed = path() !== before; const normalized = path(); - chart.setTraceOptions(id, { lineTolerancePx: 0 }); + chart.setTraceOptions(id, { line: { tolerancePx: 0 } }); const full = vertices(); - chart.setTraceOptions(id, { lineTolerancePx: 0.5 }); + chart.setTraceOptions(id, { line: { tolerancePx: 0.5 } }); const restored = path() === normalized; const png = await chart.toPng({ lineDetail: 'view', width: 300 }); const afterPng = path() === normalized; diff --git a/tests/browser/trace-style.spec.ts b/tests/browser/trace-style.spec.ts index 72bbc6a..51509c8 100644 --- a/tests/browser/trace-style.spec.ts +++ b/tests/browser/trace-style.spec.ts @@ -1,6 +1,78 @@ import { expect, test } from '@playwright/test'; import { loadLibrary } from './library'; +test('nested trace styles patch individual fields, copy snapshots, and reject invalid changes atomically', async ({ + page, +}) => { + await page.setContent('
'); + await loadLibrary(page); + const result = await page.evaluate(() => { + const chart = new window.SmithTest.Smith('#chart'); + const options = { + mode: 'both' as const, + line: { width: 3, tolerancePx: 0.5 }, + point: { radius: 4 }, + }; + const id = chart.addTrace( + [ + [1e9, 0, 0], + [2e9, 0.3, 0.2], + ], + options, + ); + options.line.width = 99; + chart.setTraceOptions(id, { line: { width: 5 } }); + chart.setTraceOptions(id, { line: { tolerancePx: undefined }, point: {} }); + const snapshot = chart.getTraces()[0]; + (snapshot.line as { width: number }).width = 77; + (snapshot.point as { radius: number }).radius = 77; + const before = chart.getTraces()[0]; + const beforeSvg = document.querySelector('svg')!.outerHTML; + const errors: string[] = []; + for (const bad of [ + { name: 'Changed', line: { width: 0 } }, + { line: { tolerancePx: -1 } }, + { point: { radius: NaN } }, + { line: null }, + { point: [] }, + { line: { unknown: 2 } }, + { point: { radius: '3' } }, + { lineWidth: 3 }, + { lineTolerancePx: 1 }, + { pointRadius: 3 }, + ]) { + try { + chart.setTraceOptions(id, bad as unknown as import('../../src').TraceOptions); + } catch (error) { + errors.push((error as Error).name); + } + } + const after = chart.getTraces()[0]; + const unchangedSvg = beforeSvg === document.querySelector('svg')!.outerHTML; + chart.destroy(); + return { before, after, errors, unchangedSvg }; + }); + expect(result.before).toMatchObject({ + mode: 'both', + line: { width: 5, tolerancePx: 0.5 }, + point: { radius: 4 }, + }); + expect(result.after).toEqual(result.before); + expect(result.unchangedSvg).toBe(true); + expect(result.errors).toEqual([ + 'RangeError', + 'RangeError', + 'RangeError', + 'TypeError', + 'TypeError', + 'TypeError', + 'RangeError', + 'TypeError', + 'TypeError', + 'TypeError', + ]); +}); + test('trace styles preserve samples, markers, visibility, and ordering across changes', async ({ page, }) => { @@ -18,7 +90,7 @@ test('trace styles preserve samples, markers, visibility, and ordering across ch { frequencyHz: 1, reflectionCoefficient: [0, 0.5] }, { frequencyHz: 2, reflectionCoefficient: [0, 0] }, ]; - const id = chart.addTrace(samples, { mode: 'line', lineWidth: 3, pointRadius: 4 }); + const id = chart.addTrace(samples, { mode: 'line', line: { width: 3 }, point: { radius: 4 } }); chart.addMarker(id); const marker = chart.getTraces()[0].markers[0].id; const count = () => ({ @@ -41,7 +113,11 @@ test('trace styles preserve samples, markers, visibility, and ordering across ch const points = count(); const before = chart.getTraces()[0].color; let invalid = 0; - for (const options of [{ lineWidth: NaN }, { pointRadius: 0 }, { mode: 'invalid' }]) { + for (const options of [ + { line: { width: NaN } }, + { point: { radius: 0 } }, + { mode: 'invalid' }, + ]) { try { chart.setTraceOptions(id, { color: 'red', ...options } as import('../../src').TraceOptions); } catch { @@ -65,7 +141,11 @@ test('trace styles preserve samples, markers, visibility, and ordering across ch width, color, hidden, - style: { mode: style.mode, lineWidth: style.lineWidth, pointRadius: style.pointRadius }, + style: { + mode: style.mode, + line: { width: style.line.width }, + point: { radius: style.point.radius }, + }, stable, invalid, atomic, @@ -80,7 +160,7 @@ test('trace styles preserve samples, markers, visibility, and ordering across ch width: '3', color: '#123456', hidden: 'none', - style: { mode: 'both', lineWidth: 3, pointRadius: 4 }, + style: { mode: 'both', line: { width: 3 }, point: { radius: 4 } }, stable: 2, invalid: 3, atomic: true,