From 3f4a337962448a07d0a6912df273e187c23eef37 Mon Sep 17 00:00:00 2001 From: Krzysztof Rosinski Date: Wed, 14 Oct 2026 06:29:39 +0200 Subject: [PATCH] feat!: group visual styles and support constructor mounting --- CHANGELOG.md | 5 + README.md | 4 +- demo/main.ts | 19 +- docs/api-review.md | 13 +- docs/api/smithkit.api.md | 155 ++++++++++++----- docs/guide/configuration.md | 67 ++++--- docs/guide/getting-started.md | 5 +- docs/migration-next.md | 72 ++++++++ docs/migrations.md | 5 + examples/appearance/main.ts | 5 +- examples/static/main.ts | 3 +- scripts/check-package.mjs | 19 +- src/OptionsValidation.ts | 36 ++-- src/Smith.ts | 45 ++++- src/appearance/SvgTheme.ts | 16 +- src/appearance/Theme.ts | 101 ++++++----- src/appearance/types.ts | 65 +++---- src/grid/CircleLayerControl.ts | 12 +- src/grid/ConstCircles.ts | 12 +- src/grid/ConstQCircles.ts | 5 +- src/grid/ConstSwrCircles.ts | 5 +- src/grid/GridLayerControl.ts | 25 ++- src/index.ts | 2 + src/interaction/SmithCursor.ts | 12 +- src/layers.ts | 42 +++-- src/options.ts | 8 +- src/rendering/SvgChartRenderer.ts | 2 +- src/scales/SmithPeripheralScales.ts | 22 +-- src/scales/SmithScales.ts | 30 ++-- src/traces/SmithMarker.ts | 10 +- tests/browser/adaptive-labels.spec.ts | 4 +- tests/browser/appearance.spec.ts | 19 +- tests/browser/chart-options.spec.ts | 183 ++++++++++++++++++-- tests/browser/grid-detail.spec.ts | 2 +- tests/browser/grid-label-visibility.spec.ts | 2 +- tests/browser/grid-style.spec.ts | 21 +-- tests/browser/label-layering.spec.ts | 2 +- tests/browser/svg-export.spec.ts | 2 +- tests/theme.test.ts | 57 ++++-- 39 files changed, 770 insertions(+), 344 deletions(-) create mode 100644 docs/migration-next.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d2722..3ba42f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased — next minor + +- **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). +- 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 - Record native Firefox 114 and Safari 26.6 verification. Describe browser compatibility through completed tests, separately from compilation targets. diff --git a/README.md b/README.md index 357392e..0114b01 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,7 @@ Mount a container with a non-zero size: ```ts import { Smith } from 'smithkit'; -const chart = new Smith({ referenceImpedanceOhms: 50 }); -chart.draw('#smith'); +const chart = new Smith('#smith', { referenceImpedanceOhms: 50 }); const traceId = chart.addTrace( [ [1e9, 0.5, -0.5], @@ -84,6 +83,7 @@ packed `Float64Array` triples are also supported. Inputs are validated and copie By default, the chart shows a labeled standard impedance grid with zoom, cursor tracking, and peripheral scales disabled. No markers are added automatically. The constructor and `setOptions()` share the same grouped configuration schema. +To mount later, use `new Smith(options)` and then `chart.draw(elementOrSelector)`. Create charts in the browser after mounting the host; call `destroy()` on unmount. Calculation, formatting, and Touchstone classes also work without a DOM. diff --git a/demo/main.ts b/demo/main.ts index 5191c21..a235cf3 100644 --- a/demo/main.ts +++ b/demo/main.ts @@ -127,18 +127,17 @@ function demoAppearance(theme: SmithAppearance['theme']): SmithAppearance { ? { overrides: { background: '#191c20', - fontFamily: 'Arial, sans-serif', - grid: { stroke: '#727984', textColor: '#c7cbd1' }, - boundary: { stroke: '#9da3ad' }, + text: { fontFamily: 'Arial, sans-serif' }, + grid: { line: { color: '#727984' }, text: { color: '#c7cbd1' } }, + boundary: { color: '#9da3ad' }, scales: { - stroke: '#727984', - boundaryColor: '#9da3ad', - textColor: '#b5bcc5', - valueColor: '#e4e7eb', - indicatorColor: '#e9ac59', - indicatorOutline: '#191c20', + line: { color: '#727984' }, + boundary: { color: '#9da3ad' }, + text: { color: '#b5bcc5' }, + value: { color: '#e4e7eb' }, + indicator: { color: '#e9ac59', outline: '#191c20' }, }, - cursor: { pointColor: '#a0bce5', impedanceColor: '#a0bce5' }, + cursor: { point: { color: '#a0bce5' }, impedance: { color: '#a0bce5' } }, }, } : {}), diff --git a/docs/api-review.md b/docs/api-review.md index b90e341..1333c42 100644 --- a/docs/api-review.md +++ b/docs/api-review.md @@ -1,6 +1,6 @@ # Public API review and compatibility policy -Reviewed against **0.5.0**, including compact trace input, optional line simplification, and Touchstone reflection I/O, +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, 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). @@ -39,9 +39,9 @@ const chart = new Smith({ grid: { detail: 'standard', labelsVisible: true, - style: { majorWidth: 1.5 }, + style: { line: { majorWidth: 1.5 } }, layers: { - resistance: { style: { stroke: '#60a5fa' } }, + resistance: { style: { line: { color: '#60a5fa' } } }, conductance: { visible: true, detail: 'basic' }, }, }, @@ -71,6 +71,13 @@ and cursor tracking run. A marker's appearance does not create that marker. Traces and markers are added separately with `addTrace()` and `addMarker()`. Report options belong to each export call, not the chart constructor. +Visual styles group line settings under `line` and labels under `text`. Theme +overrides use the same grouping, with `point`, `indicator`, `value`, and `focus` +for their respective elements. Nested style patches preserve omitted siblings. +The optional first constructor argument mounts into a CSS selector or HTMLElement; +`new Smith(options)` remains available for deferred `draw()`. The target is not a +configuration field and cannot be changed through `setOptions()`. + `setOptions(SmithOptions)` applies the same grouped schema as a patch. Omitted and `undefined` fields retain their current values, including nested fields. Empty groups are no-ops. Within each call, shared grid settings precede per-layer overrides; diff --git a/docs/api/smithkit.api.md b/docs/api/smithkit.api.md index 8ba7079..c585923 100644 --- a/docs/api/smithkit.api.md +++ b/docs/api/smithkit.api.md @@ -27,7 +27,7 @@ export interface CircleLayer { // (undocumented) removeValue(value: number): void; // (undocumented) - setStyle(style: Partial): void; + setStyle(style: CircleStyleOptions): void; setValues(values: readonly number[]): void; // (undocumented) setVisible(visible: boolean): void; @@ -36,7 +36,7 @@ export interface CircleLayer { // @public (undocumented) export interface CircleLayerOptions { // (undocumented) - style?: Partial; + style?: CircleStyleOptions; values?: readonly number[]; visible?: boolean; } @@ -52,8 +52,16 @@ export interface CircleOptions { // @public (undocumented) export interface CircleStyle { // (undocumented) - stroke: string; - strokeWidth: number; + line: { + color: string; + width: number; + }; +} + +// @public (undocumented) +export interface CircleStyleOptions { + // (undocumented) + line?: Partial; } // @public (undocumented) @@ -270,7 +278,7 @@ export interface GridLayer { setDetail(detail: GridDetail): void; setLabelsVisible(visible: boolean): void; // (undocumented) - setStyle(style: Partial): void; + setStyle(style: GridStyleOptions): void; // (undocumented) setVisible(visible: boolean): void; } @@ -282,7 +290,7 @@ export interface GridLayerOptions { // (undocumented) labelsVisible?: boolean; // (undocumented) - style?: Partial; + style?: GridStyleOptions; // (undocumented) visible?: boolean; } @@ -299,20 +307,31 @@ export interface GridOptions { susceptance?: GridLayerOptions; }; // (undocumented) - style?: Partial; + style?: GridStyleOptions; } // @public (undocumented) export interface GridStyle { - majorWidth: number; - minorWidth: number; // (undocumented) - stroke: string; + line: { + color: string; + majorWidth: number; + minorWidth: number; + }; // (undocumented) - textColor: string; + text: { + color: string; + fontFamily: string; + fontSize: number; + }; +} + +// @public +export interface GridStyleOptions { // (undocumented) - textFontFamily: string; - textFontSize: number; + line?: Partial; + // (undocumented) + text?: Partial; } // @public (undocumented) @@ -432,6 +451,7 @@ export interface ScaleImageExportOptions extends ImageExportOptions { // @public (undocumented) export class Smith { constructor(options?: SmithOptions); + constructor(target: string | HTMLElement, options?: SmithOptions); addMarker(traceId: string, sampleIndex?: number): string | undefined; addTrace(values: TraceSamples, options?: TraceOptions): string; // (undocumented) @@ -603,60 +623,103 @@ export interface SmithTheme { background: string; // (undocumented) boundary: { - stroke: string; + color: string; width: number; }; // (undocumented) circles: { - q: string; - vswr: string; - width: number; + q: { + color: string; + width: number; + }; + vswr: { + color: string; + width: number; + }; }; // (undocumented) cursor: { - pointColor: string; - impedanceColor: string; - admittanceColor: string; - pointSize: number; - lineWidth: number; + point: { + color: string; + size: number; + }; + impedance: { + color: string; + }; + admittance: { + color: string; + }; + line: { + width: number; + }; }; // (undocumented) - fontFamily: string; - // (undocumented) grid: { - stroke: string; - textColor: string; - majorWidth: number; - minorWidth: number; - fontSize: number; + line: { + color: string; + majorWidth: number; + minorWidth: number; + }; + text: { + color: string; + fontSize: number; + }; }; // (undocumented) marker: { - outlineColor: string; - textColor: string; - focusColor: string; - focusHaloColor: string; + outline: { + color: string; + }; + text: { + color: string; + }; + focus: { + color: string; + halo: string; + }; }; // (undocumented) scales: { - stroke: string; - boundaryColor: string; - textColor: string; - valueColor: string; - indicatorColor: string; - indicatorOutline: string; - fontSize: number; - titleFontSize: number; - valueFontSize: number; - peripheralFontSize: number; + line: { + color: string; + }; + boundary: { + color: string; + }; + text: { + color: string; + fontSize: number; + }; + title: { + fontSize: number; + }; + value: { + color: string; + fontSize: number; + }; + indicator: { + color: string; + outline: string; + }; + peripheral: { + fontSize: number; + }; }; // (undocumented) - traceColors: readonly string[]; + text: { + fontFamily: string; + }; + // (undocumented) + traces: { + colors: readonly string[]; + }; } -// @public (undocumented) +// @public export type SmithThemeOverrides = { - [K in keyof SmithTheme]?: SmithTheme[K] extends readonly string[] ? readonly string[] : SmithTheme[K] extends object ? Partial : SmithTheme[K]; + [K in keyof SmithTheme]?: SmithTheme[K] extends string ? string : { + [P in keyof SmithTheme[K]]?: SmithTheme[K][P] extends readonly string[] ? readonly string[] : SmithTheme[K][P] extends object ? Partial : SmithTheme[K][P]; + }; }; // @public diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index bae6ac2..4b7bfbf 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -9,7 +9,8 @@ Reference: {@link SmithOptions | configuration fields}, {@link Smith.setOptions ## Constructor configuration -`new Smith(options?)` accepts a typed `SmithOptions` object. Every field is optional: +`new Smith(options?)` and `new Smith(target, options?)` accept a typed `SmithOptions` +object. Every configuration field is optional: ```ts import { Smith } from 'smithkit'; @@ -22,16 +23,16 @@ const chart = new Smith({ grid: { detail: 'basic', labelsVisible: true, - style: { majorWidth: 1.5 }, + style: { line: { majorWidth: 1.5 } }, layers: { - resistance: { style: { stroke: '#60a5fa' } }, + resistance: { style: { line: { color: '#60a5fa' } } }, reactance: { labelsVisible: false }, conductance: { visible: true, detail: 'standard' }, }, }, circles: { q: { visible: true, values: [1, 2, 5] }, - vswr: { visible: true, values: [2, 3], style: { stroke: '#fbbf24' } }, + vswr: { visible: true, values: [2, 3], style: { line: { color: '#fbbf24' } } }, }, }); chart.draw('#smith'); @@ -72,6 +73,19 @@ The constructor configures presentation and interaction. Add measurement data wi `addTrace()` and optional markers with `addMarker()`; no markers are created implicitly. Radial parameter scales remain independently mounted `SmithScales` instances. +### Mounting during construction + +When the host already exists, pass a CSS selector or an `HTMLElement` as the first +argument: `new Smith('#smith', options)` or `new Smith(element, options)`. +Options are optional: `new Smith('#smith')` mounts the default static chart. +This is equivalent to `new Smith(options)` followed by `chart.draw(target)`. +Strings are CSS selectors (use `'#smith'`, not a bare element ID). + +Keep `new Smith(options)` and call `draw()` later when the host is not available +yet. A constructor target is a mounting argument, not part of `SmithOptions`: +`setOptions()` changes configuration without moving the chart. Missing containers, +invalid selectors, or invalid options throw; a failed mount releases its resources. + ### Updating configuration `chart.setOptions(options)` accepts the same `SmithOptions` type as the constructor: @@ -114,11 +128,11 @@ import type { SmithAppearance } from 'smithkit'; const appearance: SmithAppearance = { theme: 'dark', overrides: { - fontFamily: 'Arial, sans-serif', - grid: { majorWidth: 1.2 }, - cursor: { impedanceColor: '#fbbf24' }, - marker: { focusColor: '#38bdf8' }, - traceColors: ['#38bdf8', '#fb923c', '#a78bfa'], + text: { fontFamily: 'Arial, sans-serif' }, + grid: { line: { majorWidth: 1.2 }, text: { color: '#e2e8f0' } }, + cursor: { impedance: { color: '#fbbf24' } }, + marker: { focus: { color: '#38bdf8' } }, + traces: { colors: ['#38bdf8', '#fb923c', '#a78bfa'] }, }, }; const chart = new Smith({ referenceImpedanceOhms: 50, appearance }); @@ -136,16 +150,23 @@ settings return to the selected preset, or light when `theme` is omitted. Nested overrides are partial. Appearance is scoped to each component and does not change the surrounding application's CSS or other chart instances. -| Override | Settings | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `background`, `fontFamily` | Component background and shared font family. | -| `grid` | `stroke`, `textColor`, `majorWidth`, `minorWidth`, `fontSize`. | -| `boundary` | `stroke`, `width` for the Smith chart boundary. | -| `circles` | `q`, `vswr` colors and shared `width`. | -| `scales` | `stroke`, `boundaryColor`, `textColor`, `valueColor`, `indicatorColor`, `indicatorOutline`; `fontSize` for radial ticks, `titleFontSize`, `valueFontSize`, and `peripheralFontSize`. | -| `cursor` | `pointColor`, `impedanceColor`, `admittanceColor`, `pointSize` (dot diameter), `lineWidth`. | -| `marker` | `outlineColor`, `textColor`, `focusColor`, `focusHaloColor`. Marker fill follows its trace color. | -| `traceColors` | Non-empty palette for automatically colored traces. | +| Override | Settings | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------- | +| `background`, `text` | Component background and shared `text.fontFamily`. | +| `grid.line` | `color`, `majorWidth`, `minorWidth`. | +| `grid.text` | `color`, `fontSize`. | +| `boundary` | `color`, `width` for the Smith chart boundary. | +| `circles.q`, `circles.vswr` | Independent `color` and `width` for each circle family. | +| `scales.line`, `scales.boundary` | `color` for ticks/axes and peripheral band outlines. | +| `scales.text` | `color` and radial tick `fontSize`; the color also applies to titles and peripheral labels. | +| `scales.title`, `scales.peripheral` | `fontSize` for radial titles and peripheral labels. | +| `scales.value` | `color`, `fontSize` for readings. | +| `scales.indicator` | `color`, `outline` for reading dots. | +| `cursor.point` | `color`, `size` (dot diameter). | +| `cursor.impedance`, `cursor.admittance`, `cursor.line` | Axis `color` and shared line `width`. | +| `marker.text`, `marker.outline` | `color`; marker fill follows its trace color. | +| `marker.focus` | `color` and `halo` color. | +| `traces.colors` | Non-empty palette for automatically colored traces. | Colors must be solid CSS colors accepted by the library's color parser, including hex, RGB, HSL, named colors, and `transparent`; CSS variable references are not @@ -181,14 +202,14 @@ chart.layers.susceptance.setVisible(true); chart.layers.conductance.setDetail('standard'); chart.layers.conductance.setLabelsVisible(false); chart.layers.susceptance.setLabelsVisible(false); -chart.layers.susceptance.setStyle({ stroke: '#64748b', textColor: '#2a7f62' }); +chart.layers.susceptance.setStyle({ line: { color: '#64748b' }, text: { color: '#2a7f62' } }); chart.layers.vswr.addValue(4); chart.layers.vswr.setVisible(true); ``` The four grid layers support `setVisible`, `setLabelsVisible`, `setDetail`, and -`setStyle(Partial)`. Style fields: `stroke`, `majorWidth`, `minorWidth`, -`textColor`, `textFontFamily`, `textFontSize`. Widths are positive finite numbers +`setStyle(GridStyleOptions)`. Styles group `line.color`, `line.majorWidth`, +`line.minorWidth` and `text.color`, `text.fontFamily`, `text.fontSize`. Widths are positive finite numbers in CSS pixels. Font size is a positive finite base size in chart units; adaptive layout raises it when needed to keep the font size at least 9 CSS pixels in the default view. Partial updates retain other settings. Invalid numeric styles throw @@ -213,7 +234,7 @@ whole-layer visibility changes and is included in SVG exports. Calling layout; it does not show a hidden layer. The **Grid labels** checkbox in the demo’s **Chart settings** dialog controls all four grid layers together. Peripheral scale labels are unaffected. `layers.q` and `layers.vswr` support `setVisible`, -`setStyle({ stroke?, strokeWidth? })`, `setValues`, `addValue`, and `removeValue`. +`setStyle({ line: { color?, width? } })`, `setValues`, `addValue`, and `removeValue`. `setValues([1, 2, 5])` replaces the entire list; `setValues([])` clears it. Input arrays are copied, duplicate values are ignored, and invalid input leaves the previous values unchanged. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 7170161..c8cb37f 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -18,8 +18,7 @@ Give the container an explicit size and run the code after it is mounted: ```ts import { Smith } from 'smithkit'; -const chart = new Smith({ referenceImpedanceOhms: 50 }); // Reference impedance in ohms; defaults to 50. -chart.draw('#smith'); +const chart = new Smith('#smith', { referenceImpedanceOhms: 50 }); // Reference impedance in ohms; defaults to 50. const traceId = chart.addTrace( [ { frequencyHz: 1e9, reflectionCoefficient: [0.5, -0.5] }, @@ -42,6 +41,8 @@ and their focus indicators stay above grid labels and sample points. `chart.rese Construct `Smith` only in a browser, such as your framework's mount hook. Importing the package and using its calculation or parsing methods does not require a DOM. +Pass a CSS selector or HTMLElement first to mount immediately, or use +`new Smith(options)` and call `draw(target)` when the host becomes available. The default chart is a static presentation: standard impedance grid with labels, no peripheral scales, and zoom and cursor tracking disabled. Markers are optional diff --git a/docs/migration-next.md b/docs/migration-next.md new file mode 100644 index 0000000..9ee9b3c --- /dev/null +++ b/docs/migration-next.md @@ -0,0 +1,72 @@ +# Migration from 0.6.x to the next minor + +These changes are unreleased and require a minor version during 0.x development. +The old flat visual style keys are removed; they are not compatibility aliases. +Default appearance, interaction, grid detail, and trace behavior remain unchanged. + +## Grid and circle styles + +Use the same nested style shape in constructor options, `setOptions()`, and +individual layer `setStyle()` calls: + +```ts +chart.layers.resistance.setStyle({ + line: { color: '#64748b', majorWidth: 1.5, minorWidth: 0.6 }, + text: { color: '#111827', fontFamily: 'Arial', fontSize: 9 }, +}); +chart.layers.vswr.setStyle({ line: { color: 'orange', width: 2 } }); +``` + +| Previous grid key | New grid key | +| -------------------------------- | ------------------------------------ | +| `stroke` | `line.color` | +| `majorWidth`, `minorWidth` | `line.majorWidth`, `line.minorWidth` | +| `textColor` | `text.color` | +| `textFontFamily`, `textFontSize` | `text.fontFamily`, `text.fontSize` | + +Circle `stroke` and `strokeWidth` become `line.color` and `line.width`. +Use `GridStyleOptions` / `CircleStyleOptions` for partial patches instead of +`Partial` / `Partial`. Patches merge individual nested +properties; for example, changing `text.color` preserves the font and line style. +Unknown keys and invalid nested values are rejected before changing the chart. + +## Theme overrides + +The `appearance: { theme, overrides }` wrapper and replacement semantics stay the +same. Override paths change as follows: + +| Previous override | New override | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `fontFamily` | `text.fontFamily` | +| `traceColors` | `traces.colors` | +| `grid.stroke`, `grid.majorWidth`, `grid.minorWidth` | `grid.line.color`, `grid.line.majorWidth`, `grid.line.minorWidth` | +| `grid.textColor`, `grid.fontSize` | `grid.text.color`, `grid.text.fontSize` | +| `boundary.stroke` | `boundary.color` | +| `circles.q`, `circles.vswr` | `circles.q.color`, `circles.vswr.color` | +| `circles.width` | Set both `circles.q.width` and `circles.vswr.width` | +| `scales.stroke`, `scales.boundaryColor` | `scales.line.color`, `scales.boundary.color` | +| `scales.textColor`, `scales.fontSize` | `scales.text.color`, `scales.text.fontSize` | +| `scales.titleFontSize`, `scales.peripheralFontSize` | `scales.title.fontSize`, `scales.peripheral.fontSize` | +| `scales.valueColor`, `scales.valueFontSize` | `scales.value.color`, `scales.value.fontSize` | +| `scales.indicatorColor`, `scales.indicatorOutline` | `scales.indicator.color`, `scales.indicator.outline` | +| `cursor.pointColor`, `cursor.pointSize` | `cursor.point.color`, `cursor.point.size` | +| `cursor.impedanceColor`, `cursor.admittanceColor`, `cursor.lineWidth` | `cursor.impedance.color`, `cursor.admittance.color`, `cursor.line.width` | +| `marker.outlineColor`, `marker.textColor` | `marker.outline.color`, `marker.text.color` | +| `marker.focusColor`, `marker.focusHaloColor` | `marker.focus.color`, `marker.focus.halo` | + +These changes also apply to the appearance passed to `SmithScales`. +The complete shape is documented in the [configuration guide](guide/configuration.md). + +## Optional constructor mounting + +```ts +const chart = new Smith('#chart', { + appearance: { theme: 'dark' }, + grid: { style: { text: { color: '#e2e8f0' } } }, +}); +``` + +The first argument is a CSS selector or an `HTMLElement`. It is optional: existing +`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. diff --git a/docs/migrations.md b/docs/migrations.md index 7628e46..ebcf071 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -5,6 +5,11 @@ 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) + +Visual styles are grouped by element and chart construction can mount directly +into a container. Follow the [next minor migration guide](migration-next.md). + ## 0.4.x to 0.5.0 No migration is required. Object-based trace inputs and default Touchstone `.s1p` diff --git a/examples/appearance/main.ts b/examples/appearance/main.ts index e624bba..51f6ed8 100644 --- a/examples/appearance/main.ts +++ b/examples/appearance/main.ts @@ -1,8 +1,7 @@ import { Smith } from 'smithkit'; import type { SmithAppearance } from 'smithkit'; -const chart = new Smith({ interaction: { cursor: true } }); -chart.draw('#chart'); +const chart = new Smith('#chart', { interaction: { cursor: true } }); const traceId = chart.addTrace( [ { frequencyHz: 1e9, reflectionCoefficient: [0.4, -0.4] }, @@ -19,7 +18,7 @@ function applyTheme(): void { chart.setOptions({ appearance: { theme: select.value as SmithAppearance['theme'], - overrides: { grid: { fontSize: 11 }, cursor: { pointColor: '#f97316' } }, + overrides: { grid: { text: { fontSize: 11 } }, cursor: { point: { color: '#f97316' } } }, }, }); } diff --git a/examples/static/main.ts b/examples/static/main.ts index 5e77ae9..5b7a331 100644 --- a/examples/static/main.ts +++ b/examples/static/main.ts @@ -1,7 +1,6 @@ import { Smith } from 'smithkit'; -const chart = new Smith(); -chart.draw('#chart'); +const chart = new Smith('#chart'); // Tuples contain frequency in Hz and the real/imaginary parts of Γ. chart.addTrace( [ diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 5bb2ac0..9f85c4c 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -74,7 +74,7 @@ Touchstone.parse('1 0 0', { parameter: 'S21' }); const parsed: TouchstoneData = Touchstone.parse('# GHz S RI R 50\\n1 0 0'); import type { SmithOptions, GridOptions, GridLayerOptions, CircleLayerOptions, CircleOptions, InteractionOptions, PeripheralScalesOptions } from 'smithkit'; const grid: GridOptions = { detail: 'basic', labelsVisible: true }; -const gridLayer: GridLayerOptions = { visible: true, style: { majorWidth: 2 } }; +const gridLayer: GridLayerOptions = { visible: true, style: { line: { majorWidth: 2 } } }; const circles: CircleLayerOptions = { values: [1, 2] as const, visible: true }; const interaction: InteractionOptions = { zoom: true, cursor: true }; const peripheralScales: PeripheralScalesOptions = { visible: false }; @@ -94,6 +94,17 @@ chart.setOptions({ interaction: { cursor: false }, grid: { layers: { resistance: chart.setOptions({ cursorEnabled: true }); new Smith(); new Smith({}); +new Smith('#chart'); +new Smith(document.createElement('div'), configuration); +new Smith('#chart', { grid: { style: { text: { color: 'red' } } }, appearance: { overrides: { grid: { text: { fontSize: 10 } } } } }); +// @ts-expect-error A mounting target is separate from the configuration patch. +chart.setOptions({ target: '#chart' }); +// @ts-expect-error Removed flat grid style keys are not compatibility aliases. +chart.layers.resistance.setStyle({ textColor: 'red' }); +// @ts-expect-error Removed flat appearance keys are not compatibility aliases. +new Smith({ appearance: { overrides: { grid: { textColor: 'red' } } } }); +// @ts-expect-error Nested font sizes remain numeric. +new Smith('#chart', { grid: { style: { text: { fontSize: '10' } } } }); chart.setGridDetail('basic'); chart.setCursorEnabled(true); chart.setCursorEnabled(false); @@ -102,7 +113,7 @@ chart.setCursorEnabled('false'); // @ts-expect-error Cursor configuration requires a boolean. new Smith({ interaction: { cursor: 'true' } }); chart.layers.q.setValues([1, 2] as const); -// @ts-expect-error Positional constructor arguments are no longer supported. +// @ts-expect-error Reference impedance is a configuration field, not a mounting target. new Smith(50); // @ts-expect-error Unknown detail levels must not compile. new Smith({ grid: { detail: 'sparse' } }); @@ -195,9 +206,9 @@ const unsubscribe = chart.onEvent((event: SmithEvent) => { } }); chart.renormalize(75); -chart.layers.resistance.setStyle({ majorWidth: 2 }); +chart.layers.resistance.setStyle({ line: { majorWidth: 2 } }); // @ts-expect-error Public lengths are numeric CSS pixels, not SVG strings. -chart.layers.resistance.setStyle({ majorWidth: '2' }); +chart.layers.resistance.setStyle({ line: { majorWidth: '2' } }); chart.peripheralScales.update(Complex.zero()); unsubscribe(); chart.clearTraces(); diff --git a/src/OptionsValidation.ts b/src/OptionsValidation.ts index 96934e5..f17bd25 100644 --- a/src/OptionsValidation.ts +++ b/src/OptionsValidation.ts @@ -1,6 +1,6 @@ import { Theme } from './appearance/Theme.js'; import type { SmithOptions } from './options.js'; -import type { CircleStyle, GridDetail, GridStyle } from './layers.js'; +import type { CircleStyleOptions, GridDetail, GridStyleOptions } from './layers.js'; /** Shared validation for initial configuration, patches, and individual controls. */ export class OptionsValidation { @@ -51,23 +51,27 @@ export class OptionsValidation { } } - public static gridStyle(style: Partial): void { - OptionsValidation.object(style, 'Grid style', [ - 'stroke', - 'majorWidth', - 'minorWidth', - 'textColor', - 'textFontFamily', - 'textFontSize', - ]); - OptionsValidation.lengths(style, ['majorWidth', 'minorWidth', 'textFontSize']); - OptionsValidation.strings(style, ['stroke', 'textColor', 'textFontFamily']); + public static gridStyle(style: GridStyleOptions): void { + OptionsValidation.object(style, 'Grid style', ['line', 'text']); + if (style.line !== undefined) { + OptionsValidation.object(style.line, 'Grid line', ['color', 'majorWidth', 'minorWidth']); + OptionsValidation.lengths(style.line, ['majorWidth', 'minorWidth']); + OptionsValidation.strings(style.line, ['color']); + } + if (style.text !== undefined) { + OptionsValidation.object(style.text, 'Grid text', ['color', 'fontFamily', 'fontSize']); + OptionsValidation.lengths(style.text, ['fontSize']); + OptionsValidation.strings(style.text, ['color', 'fontFamily']); + } } - public static circleStyle(style: Partial): void { - OptionsValidation.object(style, 'Circle style', ['stroke', 'strokeWidth']); - OptionsValidation.lengths(style, ['strokeWidth']); - OptionsValidation.strings(style, ['stroke']); + public static circleStyle(style: CircleStyleOptions): void { + OptionsValidation.object(style, 'Circle style', ['line']); + if (style.line !== undefined) { + OptionsValidation.object(style.line, 'Circle line', ['color', 'width']); + OptionsValidation.lengths(style.line, ['width']); + OptionsValidation.strings(style.line, ['color']); + } } public static circleValue(value: number, minimum: number): void { diff --git a/src/Smith.ts b/src/Smith.ts index 661b565..fba801b 100644 --- a/src/Smith.ts +++ b/src/Smith.ts @@ -62,7 +62,27 @@ export class Smith { public readonly layers: ChartLayers; public readonly peripheralScales: PeripheralScales; - constructor(options: SmithOptions = {}) { + /** Configure a chart for later mounting with draw(). */ + constructor(options?: SmithOptions); + /** Configure and mount immediately. String targets are CSS selectors, as in draw(). */ + constructor(target: string | HTMLElement, options?: SmithOptions); + constructor( + targetOrOptions: string | HTMLElement | SmithOptions = {}, + configuration?: SmithOptions, + ) { + const target = + typeof targetOrOptions === 'string' || Smith.isHost(targetOrOptions) + ? targetOrOptions + : undefined; + if (target === undefined && configuration !== undefined) { + throw new TypeError('A container is required before the second options argument.'); + } + const options = + target === undefined + ? (targetOrOptions as SmithOptions) + : configuration === undefined + ? {} + : configuration; OptionsValidation.chart(options); this.referenceOhms = options.referenceImpedanceOhms === undefined ? 50 : options.referenceImpedanceOhms; @@ -81,6 +101,9 @@ export class Smith { this.peripheralScales = this.renderer.peripheralScales; try { this.applyOptions(options); + if (target !== undefined) { + this.draw(target); + } } catch (error) { // Invalid constructor options must not retain observers or event handlers. this.renderer.destroy(); @@ -88,6 +111,18 @@ 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(); @@ -167,7 +202,7 @@ export class Smith { for (const data of this.data) { const index = this.traceMetadata.get(data)!.colorIndex; if (index !== undefined) { - data.setColor(theme.traceColors[index % theme.traceColors.length]); + data.setColor(theme.traces.colors[index % theme.traces.colors.length]); } } } @@ -215,8 +250,8 @@ export class Smith { ? MarkerLegend.entries(traces, (id) => this.getMarker(id)!, options.markerLegend) : []), ], - textColor: this.theme.grid.textColor, - fontFamily: this.theme.fontFamily, + textColor: this.theme.grid.text.color, + fontFamily: this.theme.text.fontFamily, }; } @@ -528,7 +563,7 @@ export class Smith { } private createSmithData(values: TraceInput, dataset: number, options: TraceOptions): SmithData { - const color = this.theme.traceColors[dataset % this.theme.traceColors.length]; + const color = this.theme.traces.colors[dataset % this.theme.traces.colors.length]; const data = this.renderer.createTrace(values, color, options, (marker, dragging) => { this.markerDragChanged(marker, dragging); const snapshot = this.getMarker(this.markerId(marker)); diff --git a/src/appearance/SvgTheme.ts b/src/appearance/SvgTheme.ts index 2dfbad4..2e1d159 100644 --- a/src/appearance/SvgTheme.ts +++ b/src/appearance/SvgTheme.ts @@ -5,19 +5,21 @@ export class SvgTheme { private constructor() {} public static apply(element: HTMLElement | SVGElement, theme: SmithTheme): void { - for (const [section, value] of Object.entries(theme)) { + SvgTheme.tokens(element, theme, '--smithkit'); + element.style.backgroundColor = theme.background; + } + + private static tokens(element: HTMLElement | SVGElement, values: object, prefix: string): void { + for (const [key, value] of Object.entries(values)) { if (Array.isArray(value)) { continue; } if (typeof value === 'object') { - for (const [key, entry] of Object.entries(value)) { - const suffix = key.toLowerCase().endsWith('fontsize') ? 'px' : ''; - element.style.setProperty(`--smithkit-${section}-${key}`, String(entry) + suffix); - } + SvgTheme.tokens(element, value, `${prefix}-${key}`); } else { - element.style.setProperty(`--smithkit-${section}`, value); + const suffix = key === 'fontSize' ? 'px' : ''; + element.style.setProperty(`${prefix}-${key}`, String(value) + suffix); } } - element.style.backgroundColor = theme.background; } } diff --git a/src/appearance/Theme.ts b/src/appearance/Theme.ts index db7e9fa..284883c 100644 --- a/src/appearance/Theme.ts +++ b/src/appearance/Theme.ts @@ -21,66 +21,63 @@ export class Theme { const dark = name === 'dark'; const theme: SmithTheme = { background: dark ? '#0f172a' : 'transparent', - fontFamily: 'Verdana, sans-serif', + text: { fontFamily: 'Verdana, sans-serif' }, grid: { - stroke: dark ? '#94a3b8' : '#64748b', - textColor: dark ? '#e2e8f0' : '#111827', - majorWidth: 1, - minorWidth: 0.6, - fontSize: 7, + line: { color: dark ? '#94a3b8' : '#64748b', majorWidth: 1, minorWidth: 0.6 }, + text: { color: dark ? '#e2e8f0' : '#111827', fontSize: 7 }, + }, + boundary: { color: dark ? '#cbd5e1' : '#334155', width: 1 }, + circles: { + q: { color: dark ? '#93c5fd' : 'blue', width: 1 }, + vswr: { color: dark ? '#fbbf24' : 'orange', width: 1 }, }, - boundary: { stroke: dark ? '#cbd5e1' : '#334155', width: 1 }, - circles: { q: dark ? '#93c5fd' : 'blue', vswr: dark ? '#fbbf24' : 'orange', width: 1 }, scales: { - stroke: dark ? '#94a3b8' : '#64748b', - boundaryColor: dark ? '#cbd5e1' : '#334155', - textColor: dark ? '#e2e8f0' : '#334155', - valueColor: dark ? '#fca5a5' : '#b42318', - indicatorColor: dark ? '#f87171' : '#dc2626', - indicatorOutline: dark ? '#0f172a' : 'white', - fontSize: 10, - titleFontSize: 12, - valueFontSize: 11, - peripheralFontSize: 10.5, + line: { color: dark ? '#94a3b8' : '#64748b' }, + boundary: { color: dark ? '#cbd5e1' : '#334155' }, + text: { color: dark ? '#e2e8f0' : '#334155', fontSize: 10 }, + value: { color: dark ? '#fca5a5' : '#b42318', fontSize: 11 }, + indicator: { color: dark ? '#f87171' : '#dc2626', outline: dark ? '#0f172a' : 'white' }, + title: { fontSize: 12 }, + peripheral: { fontSize: 10.5 }, }, cursor: { - pointColor: dark ? '#fca5a5' : 'red', - impedanceColor: dark ? '#fca5a5' : 'red', - admittanceColor: dark ? '#86efac' : 'green', - pointSize: 5, - lineWidth: 1, + point: { color: dark ? '#fca5a5' : 'red', size: 5 }, + impedance: { color: dark ? '#fca5a5' : 'red' }, + admittance: { color: dark ? '#86efac' : 'green' }, + line: { width: 1 }, }, marker: { - outlineColor: dark ? '#e2e8f0' : 'gray', - textColor: 'white', - focusColor: dark ? '#93c5fd' : '#1d4ed8', - focusHaloColor: dark ? '#0f172a' : 'white', + outline: { color: dark ? '#e2e8f0' : 'gray' }, + text: { color: 'white' }, + focus: { color: dark ? '#93c5fd' : '#1d4ed8', halo: dark ? '#0f172a' : 'white' }, + }, + traces: { + colors: dark + ? [ + '#fb923c', + '#4ade80', + '#f87171', + '#c084fc', + '#d6a681', + '#f472b6', + '#cbd5e1', + '#d4d45c', + '#22d3ee', + '#60a5fa', + ] + : [ + '#ff7f0e', + '#2ca02c', + '#d62728', + '#9467bd', + '#8c564b', + '#e377c2', + '#7f7f7f', + '#bcbd22', + '#17becf', + '#1f77b4', + ], }, - traceColors: dark - ? [ - '#fb923c', - '#4ade80', - '#f87171', - '#c084fc', - '#d6a681', - '#f472b6', - '#cbd5e1', - '#d4d45c', - '#22d3ee', - '#60a5fa', - ] - : [ - '#ff7f0e', - '#2ca02c', - '#d62728', - '#9467bd', - '#8c564b', - '#e377c2', - '#7f7f7f', - '#bcbd22', - '#17becf', - '#1f77b4', - ], }; if (appearance.overrides !== undefined) { Theme.merge(theme as unknown as Record, appearance.overrides); diff --git a/src/appearance/types.ts b/src/appearance/types.ts index a414eae..d7e5972 100644 --- a/src/appearance/types.ts +++ b/src/appearance/types.ts @@ -1,45 +1,50 @@ /** Resolved appearance shared by charts and independent scales. Lengths are positive. */ export interface SmithTheme { background: string; - fontFamily: string; + text: { fontFamily: string }; grid: { - stroke: string; - textColor: string; - majorWidth: number; - minorWidth: number; - fontSize: number; + line: { color: string; majorWidth: number; minorWidth: number }; + text: { color: string; fontSize: number }; + }; + boundary: { color: string; width: number }; + circles: { + q: { color: string; width: number }; + vswr: { color: string; width: number }; }; - boundary: { stroke: string; width: number }; - circles: { q: string; vswr: string; width: number }; scales: { - stroke: string; - boundaryColor: string; - textColor: string; - valueColor: string; - indicatorColor: string; - indicatorOutline: string; - fontSize: number; - titleFontSize: number; - valueFontSize: number; - peripheralFontSize: number; + line: { color: string }; + boundary: { color: string }; + text: { color: string; fontSize: number }; + title: { fontSize: number }; + value: { color: string; fontSize: number }; + indicator: { color: string; outline: string }; + peripheral: { fontSize: number }; }; cursor: { - pointColor: string; - impedanceColor: string; - admittanceColor: string; - pointSize: number; - lineWidth: number; + point: { color: string; size: number }; + impedance: { color: string }; + admittance: { color: string }; + line: { width: number }; + }; + marker: { + outline: { color: string }; + text: { color: string }; + focus: { color: string; halo: string }; }; - marker: { outlineColor: string; textColor: string; focusColor: string; focusHaloColor: string }; - traceColors: readonly string[]; + traces: { colors: readonly string[] }; } +/** Each visual group can be overridden independently, down to individual properties. */ export type SmithThemeOverrides = { - [K in keyof SmithTheme]?: SmithTheme[K] extends readonly string[] - ? readonly string[] - : SmithTheme[K] extends object - ? Partial - : SmithTheme[K]; + [K in keyof SmithTheme]?: SmithTheme[K] extends string + ? string + : { + [P in keyof SmithTheme[K]]?: SmithTheme[K][P] extends readonly string[] + ? readonly string[] + : SmithTheme[K][P] extends object + ? Partial + : SmithTheme[K][P]; + }; }; /** Each application replaces the previous appearance, starting from the named preset. */ diff --git a/src/grid/CircleLayerControl.ts b/src/grid/CircleLayerControl.ts index 6990c41..6d9a51a 100644 --- a/src/grid/CircleLayerControl.ts +++ b/src/grid/CircleLayerControl.ts @@ -1,7 +1,7 @@ import { OptionsValidation } from '../OptionsValidation.js'; import type { ConstQCircles } from './ConstQCircles.js'; import type { ConstSwrCircles } from './ConstSwrCircles.js'; -import type { CircleLayer, CircleStyle } from '../layers.js'; +import type { CircleLayer, CircleStyleOptions } from '../layers.js'; import { LayerControl } from './LayerControl.js'; /** Exposes Q/VSWR circle controls without exposing the SVG renderer. */ @@ -20,14 +20,14 @@ export class CircleLayerControl extends LayerControl implements CircleLayer { this.layer.visibility(visible); } - public setStyle(style: Partial): void { + public setStyle(style: CircleStyleOptions): void { this.assertAlive(); OptionsValidation.circleStyle(style); - if (style.stroke !== undefined) { - this.layer.Stroke = style.stroke; + if (style.line?.color !== undefined) { + this.layer.Stroke = style.line.color; } - if (style.strokeWidth !== undefined) { - this.layer.StrokeWidth = String(style.strokeWidth); + if (style.line?.width !== undefined) { + this.layer.StrokeWidth = String(style.line.width); } } diff --git a/src/grid/ConstCircles.ts b/src/grid/ConstCircles.ts index 199e684..3d1dc36 100644 --- a/src/grid/ConstCircles.ts +++ b/src/grid/ConstCircles.ts @@ -145,12 +145,12 @@ export abstract class ConstCircles { protected defaultDrawingOptions(): ConstCirclesDrawOptions { return { // Keep curved grid strokes visible at normal browser zoom and low pixel density. - stroke: 'var(--smithkit-grid-stroke)', - majorWidth: 'var(--smithkit-grid-majorWidth)', - minorWidth: 'var(--smithkit-grid-minorWidth)', - textColor: 'var(--smithkit-grid-textColor)', - textFontFamily: 'var(--smithkit-fontFamily)', - textFontSize: 'var(--smithkit-grid-fontSize)', + stroke: 'var(--smithkit-grid-line-color)', + majorWidth: 'var(--smithkit-grid-line-majorWidth)', + minorWidth: 'var(--smithkit-grid-line-minorWidth)', + textColor: 'var(--smithkit-grid-text-color)', + textFontFamily: 'var(--smithkit-text-fontFamily)', + textFontSize: 'var(--smithkit-grid-text-fontSize)', }; } diff --git a/src/grid/ConstQCircles.ts b/src/grid/ConstQCircles.ts index 7457239..f304f53 100644 --- a/src/grid/ConstQCircles.ts +++ b/src/grid/ConstQCircles.ts @@ -41,7 +41,10 @@ export class ConstQCircles { } private getDefaultDrawOptions(): ConstQDrawOptions { - return { stroke: 'var(--smithkit-circles-q)', strokeWidth: 'var(--smithkit-circles-width)' }; + return { + stroke: 'var(--smithkit-circles-q-color)', + strokeWidth: 'var(--smithkit-circles-q-width)', + }; } public setDrawOptions(opts: ConstQDrawOptions): void { diff --git a/src/grid/ConstSwrCircles.ts b/src/grid/ConstSwrCircles.ts index 318d4e4..a2fc810 100644 --- a/src/grid/ConstSwrCircles.ts +++ b/src/grid/ConstSwrCircles.ts @@ -38,7 +38,10 @@ export class ConstSwrCircles { } private getDefaultDrawOptions(): ConstSwrDrawOptions { - return { stroke: 'var(--smithkit-circles-vswr)', strokeWidth: 'var(--smithkit-circles-width)' }; + return { + stroke: 'var(--smithkit-circles-vswr-color)', + strokeWidth: 'var(--smithkit-circles-vswr-width)', + }; } public setDrawOptions(opts: ConstSwrDrawOptions): void { diff --git a/src/grid/GridLayerControl.ts b/src/grid/GridLayerControl.ts index d4b1391..50e41ff 100644 --- a/src/grid/GridLayerControl.ts +++ b/src/grid/GridLayerControl.ts @@ -1,6 +1,6 @@ import { OptionsValidation } from '../OptionsValidation.js'; import type { SmithGridLayer } from './SmithGridLayer.js'; -import type { GridDetail, GridLayer, GridStyle } from '../layers.js'; +import type { GridDetail, GridLayer, GridStyleOptions } from '../layers.js'; import { LayerControl } from './LayerControl.js'; /** Exposes grid controls without exposing the SVG renderer. */ @@ -34,21 +34,20 @@ export class GridLayerControl extends LayerControl implements GridLayer { this.changed(); } - public setStyle(style: Partial): void { + public setStyle(style: GridStyleOptions): void { this.assertAlive(); OptionsValidation.gridStyle(style); - const properties = { - stroke: 'Stroke', - majorWidth: 'MajorWidth', - minorWidth: 'MinorWidth', - textColor: 'TextColor', - textFontFamily: 'TextFontFamily', - textFontSize: 'TextFontSize', - } as const; - for (const key of Object.keys(properties) as (keyof GridStyle)[]) { - const value = style[key]; + const properties = [ + ['Stroke', style.line?.color], + ['MajorWidth', style.line?.majorWidth], + ['MinorWidth', style.line?.minorWidth], + ['TextColor', style.text?.color], + ['TextFontFamily', style.text?.fontFamily], + ['TextFontSize', style.text?.fontSize], + ] as const; + for (const [property, value] of properties) { if (value !== undefined) { - this.layer[properties[key]] = String(value); + this.layer[property] = String(value); } } this.changed(); diff --git a/src/index.ts b/src/index.ts index a89e3ec..d7bac9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,9 @@ export { RfCalculations } from './rf/RfCalculations.js'; export type { SmithReading } from './rf/RfCalculations.js'; export type { GridStyle, + GridStyleOptions, CircleStyle, + CircleStyleOptions, GridLayer, GridDetail, CircleLayer, diff --git a/src/interaction/SmithCursor.ts b/src/interaction/SmithCursor.ts index 123e644..3421a13 100644 --- a/src/interaction/SmithCursor.ts +++ b/src/interaction/SmithCursor.ts @@ -39,8 +39,8 @@ export class SmithCursor { this.impedance = { group: new SmithGroup({ - stroke: 'var(--smithkit-cursor-impedanceColor)', - strokeWidth: 'var(--smithkit-cursor-lineWidth)', + stroke: 'var(--smithkit-cursor-impedance-color)', + strokeWidth: 'var(--smithkit-cursor-line-width)', fill: 'none', }), resistance: { circle: new SmithCircle({ p: [0, 0], r: 1 }) }, @@ -55,8 +55,8 @@ export class SmithCursor { this.admittance = { group: new SmithGroup({ - stroke: 'var(--smithkit-cursor-admittanceColor)', - strokeWidth: 'var(--smithkit-cursor-lineWidth)', + stroke: 'var(--smithkit-cursor-admittance-color)', + strokeWidth: 'var(--smithkit-cursor-line-width)', fill: 'none', }), conductance: { circle: new SmithCircle({ p: [0, 0], r: 1 }) }, @@ -70,8 +70,8 @@ export class SmithCursor { this.admittance.susceptance.line.nonScalingStroke(); this.point = new SmithLine([0, 0], [0, 0], { - stroke: 'var(--smithkit-cursor-pointColor)', - strokeWidth: 'var(--smithkit-cursor-pointSize)', + stroke: 'var(--smithkit-cursor-point-color)', + strokeWidth: 'var(--smithkit-cursor-point-size)', fill: 'none', }); this.point.nonScalingStroke(); diff --git a/src/layers.ts b/src/layers.ts index ed9edc1..039dde7 100644 --- a/src/layers.ts +++ b/src/layers.ts @@ -1,20 +1,34 @@ import type { Complex } from './math/Complex.js'; export interface GridStyle { - stroke: string; - /** Non-scaling stroke width in CSS pixels. */ - majorWidth: number; - /** Non-scaling stroke width in CSS pixels. */ - minorWidth: number; - textColor: string; - textFontFamily: string; - /** Base font size in chart units; adaptive layout enforces a 9 CSS px minimum in the default view. */ - textFontSize: number; + line: { + color: string; + /** Non-scaling stroke width in CSS pixels. */ + majorWidth: number; + /** Non-scaling stroke width in CSS pixels. */ + minorWidth: number; + }; + text: { + color: string; + fontFamily: string; + /** Base font size in chart units; adaptive layout enforces a 9 CSS px minimum in the default view. */ + fontSize: number; + }; +} +/** A grid style patch. Omitted nested properties retain their existing values. */ +export interface GridStyleOptions { + line?: Partial; + text?: Partial; } export interface CircleStyle { - stroke: string; - /** Non-scaling stroke width in CSS pixels. */ - strokeWidth: number; + line: { + color: string; + /** Non-scaling stroke width in CSS pixels. */ + width: number; + }; +} +export interface CircleStyleOptions { + line?: Partial; } export type GridDetail = 'basic' | 'standard' | 'detailed'; @@ -26,12 +40,12 @@ export interface GridLayer { setLabelsVisible(visible: boolean): void; /** Default: standard. Basic shows only the principal normalized values. */ setDetail(detail: GridDetail): void; - setStyle(style: Partial): void; + setStyle(style: GridStyleOptions): void; } export interface CircleLayer { setVisible(visible: boolean): void; - setStyle(style: Partial): void; + setStyle(style: CircleStyleOptions): void; /** Replace the complete set after validation; duplicates are ignored and empty clears it. */ setValues(values: readonly number[]): void; addValue(value: number): void; diff --git a/src/options.ts b/src/options.ts index b6fb1d8..05454ec 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,12 +1,12 @@ import type { SmithAppearance } from './appearance/types.js'; -import type { CircleStyle, GridDetail, GridStyle } from './layers.js'; +import type { CircleStyleOptions, GridDetail, GridStyleOptions } from './layers.js'; /** Settings for an individual impedance/admittance grid layer. */ export interface GridLayerOptions { visible?: boolean; detail?: GridDetail; labelsVisible?: boolean; - style?: Partial; + style?: GridStyleOptions; } /** Shared settings for all four grids, followed by per-layer overrides. */ @@ -15,7 +15,7 @@ export interface GridOptions { detail?: GridDetail; /** Default: true. */ labelsVisible?: boolean; - style?: Partial; + style?: GridStyleOptions; layers?: { resistance?: GridLayerOptions; reactance?: GridLayerOptions; @@ -27,7 +27,7 @@ export interface GridOptions { export interface CircleLayerOptions { /** Default: false. */ visible?: boolean; - style?: Partial; + style?: CircleStyleOptions; /** Replace the default values. Empty hides all circles; duplicate values are ignored. */ values?: readonly number[]; } diff --git a/src/rendering/SvgChartRenderer.ts b/src/rendering/SvgChartRenderer.ts index f580749..1ac7829 100644 --- a/src/rendering/SvgChartRenderer.ts +++ b/src/rendering/SvgChartRenderer.ts @@ -129,7 +129,7 @@ export class SvgChartRenderer { const cursorContainer = this.cursorContainer(); this.reactanceAxis = this.drawReactanceAxis({ - stroke: 'var(--smithkit-boundary-stroke)', + stroke: 'var(--smithkit-boundary-color)', strokeWidth: 'var(--smithkit-boundary-width)', fill: 'none', }); diff --git a/src/scales/SmithPeripheralScales.ts b/src/scales/SmithPeripheralScales.ts index f6e758d..c95ceb4 100644 --- a/src/scales/SmithPeripheralScales.ts +++ b/src/scales/SmithPeripheralScales.ts @@ -51,7 +51,7 @@ export class SmithPeripheralScales extends SmithGroup { .attr('cy', 250) .attr('r', (radius) => radius) .attr('fill', 'none') - .attr('stroke', 'var(--smithkit-scales-boundaryColor)') + .attr('stroke', 'var(--smithkit-scales-boundary-color)') .attr('stroke-width', 1.1) .attr('vector-effect', 'non-scaling-stroke'); this.axes.each(function (scale, index) { @@ -69,7 +69,7 @@ export class SmithPeripheralScales extends SmithGroup { .attr('cy', 250) .attr('r', radius) .attr('fill', 'none') - .attr('stroke', 'var(--smithkit-scales-stroke)') + .attr('stroke', 'var(--smithkit-scales-line-color)') .attr('stroke-width', 0.6) .attr('vector-effect', 'non-scaling-stroke'); } @@ -84,7 +84,7 @@ export class SmithPeripheralScales extends SmithGroup { .attr('y1', y1) .attr('x2', x2) .attr('y2', y2) - .attr('stroke', 'var(--smithkit-scales-stroke)') + .attr('stroke', 'var(--smithkit-scales-line-color)') .attr('stroke-width', major ? 0.8 : 0.5) .attr('vector-effect', 'non-scaling-stroke'); const captionDistance = Math.abs(((angle - captionAngle + 540) % 360) - 180); @@ -100,9 +100,9 @@ export class SmithPeripheralScales extends SmithGroup { .attr('transform', `rotate(${rotation},${x},${y})`) .attr('text-anchor', 'middle') .attr('dominant-baseline', 'central') - .attr('font-family', 'var(--smithkit-fontFamily)') - .attr('font-size', 'var(--smithkit-scales-peripheralFontSize)') - .attr('fill', 'var(--smithkit-scales-textColor)') + .attr('font-family', 'var(--smithkit-text-fontFamily)') + .attr('font-size', 'var(--smithkit-scales-peripheral-fontSize)') + .attr('fill', 'var(--smithkit-scales-text-color)') .text(scale.unit === 'λ' ? value.toFixed(2) : String(value)); } } @@ -126,11 +126,11 @@ export class SmithPeripheralScales extends SmithGroup { .append('text') .attr('class', 'peripheral-caption') .attr('text-anchor', 'middle') - .attr('font-family', 'var(--smithkit-fontFamily)') - .attr('font-size', 'var(--smithkit-scales-peripheralFontSize)') + .attr('font-family', 'var(--smithkit-text-fontFamily)') + .attr('font-size', 'var(--smithkit-scales-peripheral-fontSize)') .attr('font-weight', 500) .attr('letter-spacing', 0.3) - .attr('fill', 'var(--smithkit-scales-textColor)') + .attr('fill', 'var(--smithkit-scales-text-color)') .append('textPath') .attr('href', `#${pathId}`) .attr('startOffset', '50%') @@ -140,8 +140,8 @@ export class SmithPeripheralScales extends SmithGroup { .attr('class', 'peripheral-indicator') .attr('r', 3) .attr('visibility', 'hidden') - .attr('fill', 'var(--smithkit-scales-indicatorColor)') - .attr('stroke', 'var(--smithkit-scales-indicatorOutline)') + .attr('fill', 'var(--smithkit-scales-indicator-color)') + .attr('stroke', 'var(--smithkit-scales-indicator-outline)') .attr('stroke-width', 1); }); this.updateLabelVisibility(); diff --git a/src/scales/SmithScales.ts b/src/scales/SmithScales.ts index 59b508c..da51116 100644 --- a/src/scales/SmithScales.ts +++ b/src/scales/SmithScales.ts @@ -39,9 +39,9 @@ export class SmithScales { .append('text') .attr('x', this.start) .attr('y', 12) - .attr('font-family', 'var(--smithkit-fontFamily)') - .attr('font-size', 'var(--smithkit-scales-titleFontSize)') - .attr('fill', 'var(--smithkit-scales-textColor)') + .attr('font-family', 'var(--smithkit-text-fontFamily)') + .attr('font-size', 'var(--smithkit-scales-title-fontSize)') + .attr('fill', 'var(--smithkit-scales-text-color)') .text((scale) => scale.title); labels .append('text') @@ -49,10 +49,10 @@ export class SmithScales { .attr('data-role', 'scale-value') .attr('x', this.start) .attr('y', 25) - .attr('font-family', 'var(--smithkit-fontFamily)') - .attr('font-size', 'var(--smithkit-scales-valueFontSize)') + .attr('font-family', 'var(--smithkit-text-fontFamily)') + .attr('font-size', 'var(--smithkit-scales-value-fontSize)') .attr('font-weight', 600) - .attr('fill', 'var(--smithkit-scales-valueColor)') + .attr('fill', 'var(--smithkit-scales-value-color)') .text('—'); this.axes .append('line') @@ -61,7 +61,7 @@ export class SmithScales { .attr('x2', this.start + this.length) .attr('y1', 36) .attr('y2', 36) - .attr('stroke', 'var(--smithkit-scales-stroke)') + .attr('stroke', 'var(--smithkit-scales-line-color)') .attr('stroke-width', 1); const start = this.start; const length = this.length; @@ -75,7 +75,7 @@ export class SmithScales { ticks .append('line') .attr('y2', 5) - .attr('stroke', 'var(--smithkit-scales-stroke)') + .attr('stroke', 'var(--smithkit-scales-line-color)') .attr('stroke-width', 1); select(this) .select('[data-role=labels]') @@ -85,9 +85,9 @@ export class SmithScales { .attr('transform', (value) => `translate(${start + length * scale.position(value)},36)`) .append('text') .attr('y', 17) - .attr('font-size', 'var(--smithkit-scales-fontSize)') - .attr('font-family', 'var(--smithkit-fontFamily)') - .attr('fill', 'var(--smithkit-scales-textColor)') + .attr('font-size', 'var(--smithkit-scales-text-fontSize)') + .attr('font-family', 'var(--smithkit-text-fontFamily)') + .attr('fill', 'var(--smithkit-scales-text-color)') .attr('text-anchor', (_, i) => i === 0 ? 'start' : i === scale.values.length - 1 ? 'end' : 'middle', ) @@ -100,8 +100,8 @@ export class SmithScales { .attr('cx', this.start) .attr('cy', 36) .attr('r', 4) - .attr('fill', 'var(--smithkit-scales-indicatorColor)') - .attr('stroke', 'var(--smithkit-scales-indicatorOutline)') + .attr('fill', 'var(--smithkit-scales-indicator-color)') + .attr('stroke', 'var(--smithkit-scales-indicator-outline)') .attr('stroke-width', 1); labels.raise(); this.axes.append('title').text((scale) => scale.description ?? scale.title); @@ -177,8 +177,8 @@ export class SmithScales { readout?.label ? { text: readout.label, - color: style.getPropertyValue('--smithkit-scales-textColor').trim(), - fontFamily: style.getPropertyValue('--smithkit-fontFamily').trim(), + color: style.getPropertyValue('--smithkit-scales-text-color').trim(), + fontFamily: style.getPropertyValue('--smithkit-text-fontFamily').trim(), } : undefined, ); diff --git a/src/traces/SmithMarker.ts b/src/traces/SmithMarker.ts index 125f440..3e6bab5 100644 --- a/src/traces/SmithMarker.ts +++ b/src/traces/SmithMarker.ts @@ -68,8 +68,8 @@ export class SmithMarker extends SmithShape { .attr('pointer-events', 'none') .attr('aria-hidden', 'true'); for (const [stroke, width] of [ - ['var(--smithkit-marker-focusHaloColor)', 5], - ['var(--smithkit-marker-focusColor)', 2.5], + ['var(--smithkit-marker-focus-halo)', 5], + ['var(--smithkit-marker-focus-color)', 2.5], ] as const) { this.focusRing .append('circle') @@ -81,17 +81,17 @@ export class SmithMarker extends SmithShape { this.triangle = g .append('polygon') .attr('stroke', 'none') - .attr('fill', 'var(--smithkit-marker-outlineColor)'); + .attr('fill', 'var(--smithkit-marker-outline-color)'); this.inner = g.append('polygon').attr('stroke', 'none').attr('fill', color); this.text = g .append('text') .attr('pointer-events', 'none') .attr('aria-hidden', 'true') - .attr('font-family', 'var(--smithkit-fontFamily)') + .attr('font-family', 'var(--smithkit-text-fontFamily)') .attr('font-weight', 'normal') .attr('text-anchor', 'middle') .attr('dominant-baseline', 'middle') - .attr('fill', 'var(--smithkit-marker-textColor)') + .attr('fill', 'var(--smithkit-marker-text-color)') .text(marker.toString()); this.zoom(1); diff --git a/tests/browser/adaptive-labels.spec.ts b/tests/browser/adaptive-labels.spec.ts index fb1eee5..7ad1da6 100644 --- a/tests/browser/adaptive-labels.spec.ts +++ b/tests/browser/adaptive-labels.spec.ts @@ -128,7 +128,7 @@ test('layout reacts to layer visibility, text styling, zoom and reset', async ({ const initial = visible().map((label) => label.outerHTML); chart.layers.conductance.setVisible(true); chart.layers.susceptance.setVisible(true); - chart.layers.resistance.setStyle({ textFontFamily: 'monospace', textFontSize: 18 }); + chart.layers.resistance.setStyle({ text: { fontFamily: 'monospace', fontSize: 18 } }); const boxes = visible() .filter((label) => !label.closest('[data-label-layer=peripheral-scales]')) .map((label) => label.getBoundingClientRect()); @@ -144,7 +144,7 @@ test('layout reacts to layer visibility, text styling, zoom and reset', async ({ const admittance = visible().some((label) => label.closest('[data-label-layer=conductance]')); chart.layers.conductance.setVisible(false); chart.layers.susceptance.setVisible(false); - chart.layers.resistance.setStyle({ textFontFamily: 'Verdana', textFontSize: 7 }); + chart.layers.resistance.setStyle({ text: { fontFamily: 'Verdana', fontSize: 7 } }); chart.layers.resistance.setVisible(true); chart.layers.reactance.setVisible(true); const restored = visible().map((label) => label.outerHTML); diff --git a/tests/browser/appearance.spec.ts b/tests/browser/appearance.spec.ts index d03f694..fe571b4 100644 --- a/tests/browser/appearance.spec.ts +++ b/tests/browser/appearance.spec.ts @@ -43,9 +43,8 @@ test('live themes preserve chart state, focus, explicit styles, and other chart const marker = chart.getTraces()[0].markers[0].id; chart.setMarkerSample(marker, 1); chart.layers.resistance.setStyle({ - stroke: 'purple', - textFontFamily: 'Georgia', - majorWidth: 2, + line: { color: 'purple', majorWidth: 2 }, + text: { fontFamily: 'Georgia' }, }); chart.layers.conductance.setVisible(true); chart.layers.reactance.setDetail('standard'); @@ -69,12 +68,12 @@ test('live themes preserve chart state, focus, explicit styles, and other chart chart.setAppearance({ theme: 'dark', overrides: { - fontFamily: 'Arial', - marker: { focusColor: 'yellow' }, - cursor: { impedanceColor: 'cyan' }, + text: { fontFamily: 'Arial' }, + marker: { focus: { color: 'yellow' } }, + cursor: { impedance: { color: 'cyan' } }, }, }); - scales.setAppearance({ theme: 'dark', overrides: { fontFamily: 'Arial' } }); + scales.setAppearance({ theme: 'dark', overrides: { text: { fontFamily: 'Arial' } } }); const style = (selector: string) => getComputedStyle(document.querySelector(selector)!); const themed = { background: style('#chart svg').backgroundColor, @@ -101,7 +100,7 @@ test('live themes preserve chart state, focus, explicit styles, and other chart chart.setAppearance({}); const resetColors = chart.getTraces().map((t) => t.color); const resetFocus = style('#chart .marker-focus circle:last-child').stroke; - chart.setAppearance({ overrides: { traceColors: ['pink', 'teal'] } }); + chart.setAppearance({ overrides: { traces: { colors: ['pink', 'teal'] } } }); const customColors = chart.getTraces().map((t) => t.color); return { themed, addedColor, resetColors, resetFocus, customColors }; }); @@ -150,7 +149,7 @@ test('appearance validation is atomic and retained components reject changes aft let failures = 0; for (const component of [chart, scales]) { try { - component.setAppearance({ theme: 'light', overrides: { cursor: { lineWidth: -1 } } }); + component.setAppearance({ theme: 'light', overrides: { cursor: { line: { width: -1 } } } }); } catch { failures++; } @@ -179,7 +178,7 @@ test('dark SVG exports resolve theme tokens and include independent backgrounds' const { Smith, SmithScales, Complex } = window.SmithTest; const appearance: import('../../src').SmithAppearance = { theme: 'dark', - overrides: { fontFamily: 'Arial', scales: { indicatorColor: '#00ffff' } }, + overrides: { text: { fontFamily: 'Arial' }, scales: { indicator: { color: '#00ffff' } } }, }; const chart = new Smith({ referenceImpedanceOhms: 50, diff --git a/tests/browser/chart-options.spec.ts b/tests/browser/chart-options.spec.ts index 9b0dab0..8ca4a10 100644 --- a/tests/browser/chart-options.spec.ts +++ b/tests/browser/chart-options.spec.ts @@ -6,6 +6,163 @@ test.beforeEach(async ({ page }) => { await loadLibrary(page); }); +test('constructor mounts selectors and elements while supporting deferred mounting', async ({ + page, +}) => { + const result = await page.evaluate(() => { + const { Smith } = window.SmithTest; + const host = document.querySelector('#chart')!; + const first = new Smith('#chart', { + grid: { style: { text: { color: '#123456', fontFamily: 'Arial' }, line: { majorWidth: 2 } } }, + }); + const initial = getComputedStyle(host.querySelector('[data-label-layer=resistance]')!).fill; + first.setOptions({ grid: { style: { text: { color: '#abcdef' } } } }); + const labels = getComputedStyle(host.querySelector('[data-label-layer=resistance]')!); + const font = labels.fontFamily; + const color = labels.fill; + const width = getComputedStyle( + host.querySelector('[data-layer=resistance] > g:nth-child(2) path')!, + ).strokeWidth; + first.destroy(); + const second = new Smith(host); + const mountedElement = host.querySelectorAll('svg').length; + second.destroy(); + const deferred = new Smith({ grid: { detail: 'basic' } }); + const beforeDraw = host.childElementCount; + deferred.draw(host); + const afterDraw = host.querySelectorAll('svg').length; + deferred.destroy(); + const frame = document.createElement('iframe'); + document.body.append(frame); + const framed = new Smith(frame.contentDocument!.body); + const mountedFrame = frame.contentDocument!.querySelectorAll('svg').length; + framed.destroy(); + frame.remove(); + return { + initial, + font, + color, + width, + mountedElement, + beforeDraw, + afterDraw, + mountedFrame, + remaining: host.childElementCount, + }; + }); + expect(result).toEqual({ + initial: 'rgb(18, 52, 86)', + font: 'Arial', + color: 'rgb(171, 205, 239)', + width: '2px', + mountedElement: 1, + beforeDraw: 0, + afterDraw: 1, + mountedFrame: 1, + remaining: 0, + }); +}); + +test('failed constructor mounts release observers and invalid nested options mount nothing', async ({ + page, +}) => { + const result = await page.evaluate(() => { + const NativeObserver = window.ResizeObserver; + let created = 0; + let disconnected = 0; + window.ResizeObserver = class extends NativeObserver { + constructor(callback: ResizeObserverCallback) { + super(callback); + created++; + } + disconnect(): void { + disconnected++; + super.disconnect(); + } + }; + const failures: string[] = []; + try { + for (const target of ['#missing', '[']) { + try { + new window.SmithTest.Smith(target); + } catch (error) { + failures.push((error as Error).name); + } + } + try { + new window.SmithTest.Smith('#chart', { grid: { style: { text: { fontSize: -1 } } } }); + } catch (error) { + failures.push((error as Error).name); + } + return { created, disconnected, failures, mounted: document.querySelectorAll('svg').length }; + } finally { + window.ResizeObserver = NativeObserver; + } + }); + expect(result).toEqual({ + created: 2, + disconnected: 2, + failures: ['Error', 'SyntaxError', 'RangeError'], + mounted: 0, + }); +}); + +test('nested style patches preserve siblings and reject invalid children atomically', async ({ + page, +}) => { + const result = await page.evaluate(() => { + const chart = new window.SmithTest.Smith('#chart', { + grid: { + style: { + text: { fontFamily: 'Arial', color: '#123456' }, + line: { color: 'blue', majorWidth: 2 }, + }, + layers: { resistance: { style: { text: { color: 'red' } } } }, + }, + }); + const read = () => { + const labels = getComputedStyle(document.querySelector('[data-label-layer=resistance]')!); + const line = getComputedStyle( + document.querySelector('[data-layer=resistance] > g:nth-child(2) path')!, + ); + return { + font: labels.fontFamily, + color: labels.fill, + line: line.stroke, + width: line.strokeWidth, + }; + }; + const before = read(); + const failures: string[] = []; + for (const style of [ + { line: { color: 'green' }, text: { fontSize: -1 } }, + { text: { unknown: true } }, + { text: null }, + { textColor: 'purple' }, + ]) { + try { + chart.layers.resistance.setStyle(style as unknown as import('../../src').GridStyleOptions); + } catch (error) { + failures.push((error as Error).name); + } + } + const afterRejected = read(); + chart.layers.resistance.setStyle({ text: {}, line: { color: undefined } }); + const afterEmpty = read(); + chart.destroy(); + return { before, afterRejected, afterEmpty, failures }; + }); + expect(result.before).toEqual({ + font: 'Arial', + color: 'rgb(255, 0, 0)', + line: 'rgb(0, 0, 255)', + width: '2px', + }); + expect(result.afterRejected).toEqual(result.before); + expect(result.afterEmpty).toEqual(result.before); + expect(result.failures).toEqual(['RangeError', 'TypeError', 'TypeError', 'TypeError']); +}); + test('constructor settings apply shared defaults, then per-layer overrides without retaining input', async ({ page, }) => { @@ -18,19 +175,19 @@ test('constructor settings apply shared defaults, then per-layer overrides witho grid: { detail: 'basic', labelsVisible: false, - style: { stroke: '#123456', majorWidth: 2 }, + style: { line: { color: '#123456', majorWidth: 2 } }, layers: { - resistance: { labelsVisible: true, style: { stroke: '#abcdef' } }, + resistance: { labelsVisible: true, style: { line: { color: '#abcdef' } } }, conductance: { visible: true, detail: 'standard' }, }, }, circles: { - q: { visible: true, values: [1, 2, 2], style: { stroke: '#654321', strokeWidth: 3 } }, - vswr: { visible: true, values: [], style: { stroke: '#fedcba' } }, + q: { visible: true, values: [1, 2, 2], style: { line: { color: '#654321', width: 3 } } }, + vswr: { visible: true, values: [], style: { line: { color: '#fedcba' } } }, }, }; const chart = new window.SmithTest.Smith(options); - options.grid!.style!.majorWidth = 8; + options.grid!.style!.line!.majorWidth = 8; options.grid!.detail = 'detailed'; options.circles!.q!.values = [5]; chart.draw('#chart'); @@ -137,7 +294,7 @@ test('invalid constructor settings reject and disconnect allocated observers', a { peripheralScales: { phase: false } }, { grid: { layers: [] } }, { grid: { detail: 'invalid' } }, - { grid: { style: { majorWidth: -1 } } }, + { grid: { style: { line: { majorWidth: -1 } } } }, { grid: { layers: { resistance: { labelsVisible: 1 } } } }, { circles: { q: { values: [1, 0] } } }, { circles: { vswr: { values: [0.5] } } }, @@ -164,8 +321,8 @@ test('shared detail and circle replacement preserve presentation and reject inva const result = await page.evaluate(() => { const values = [1, 2]; const chart = new window.SmithTest.Smith({ - grid: { labelsVisible: false, style: { stroke: '#123456' } }, - circles: { q: { values, style: { stroke: '#654321' } } }, + grid: { labelsVisible: false, style: { line: { color: '#123456' } } }, + circles: { q: { values, style: { line: { color: '#654321' } } } }, }); chart.draw('#chart'); values.push(5); @@ -441,7 +598,7 @@ test('setOptions patches supplied fields and renormalizes data without resetting const chart = new window.SmithTest.Smith({ appearance: { theme: 'dark' }, interaction: { zoom: true }, - grid: { style: { stroke: '#123456', majorWidth: 2 } }, + grid: { style: { line: { color: '#123456', majorWidth: 2 } } }, }); chart.draw('#chart'); const trace = chart.addTrace([{ frequencyHz: 1e9, reflectionCoefficient: [0.5, 0] }]); @@ -462,8 +619,8 @@ test('setOptions patches supplied fields and renormalizes data without resetting const options: import('../../src').SmithOptions = { referenceImpedanceOhms: 75, interaction: { cursor: true }, - grid: { detail: 'basic', layers: { resistance: { style: { majorWidth: 3 } } } }, - circles: { vswr: { values, visible: true, style: { stroke: '#abcdef' } } }, + grid: { detail: 'basic', layers: { resistance: { style: { line: { majorWidth: 3 } } } } }, + circles: { vswr: { values, visible: true, style: { line: { color: '#abcdef' } } } }, peripheralScales: { visible: true }, }; chart.setOptions(options); @@ -538,11 +695,11 @@ test('configuration patches reject all invalid fields before any mutation, inclu const svg = document.querySelector('svg')!; const before = svg.outerHTML; const patches: unknown[] = [ - { appearance: { theme: 'dark' }, grid: { style: { majorWidth: -1 } } }, + { appearance: { theme: 'dark' }, grid: { style: { line: { majorWidth: -1 } } } }, { interaction: { cursor: true }, circles: { vswr: { values: [2, 0] } } }, { grid: { detail: 'basic', layers: { resistance: { visible: 'false' } } } }, { grid: { style: { unknown: 1 } } }, - { grid: { detail: 'basic', style: { textColor: null } } }, + { grid: { detail: 'basic', style: { text: { color: null } } } }, { appearance: { unknown: true } }, { referenceImpedanceOhms: 75, diff --git a/tests/browser/grid-detail.spec.ts b/tests/browser/grid-detail.spec.ts index 0ec6dc1..11d7976 100644 --- a/tests/browser/grid-detail.spec.ts +++ b/tests/browser/grid-detail.spec.ts @@ -26,7 +26,7 @@ for (const theme of ['light', 'dark'] as const) { const [minor, major] = group.children; const labels = document.querySelector(`[data-label-layer=${name}]`)!; const original = major.innerHTML; - layer.setStyle({ stroke: '#123456', majorWidth: 2 }); + layer.setStyle({ line: { color: '#123456', majorWidth: 2 } }); layer.setDetail('basic'); const hidden = group.getAttribute('opacity'); layer.setVisible(true); diff --git a/tests/browser/grid-label-visibility.spec.ts b/tests/browser/grid-label-visibility.spec.ts index 7fa9428..d470f35 100644 --- a/tests/browser/grid-label-visibility.spec.ts +++ b/tests/browser/grid-label-visibility.spec.ts @@ -32,7 +32,7 @@ test('grid labels toggle independently and preserve their preference through upd .filter((group) => group !== labels) .every((group) => getComputedStyle(group).display !== 'none'); layer.setDetail('basic'); - layer.setStyle({ textColor: '#123456' }); + layer.setStyle({ text: { color: '#123456' } }); chart.setAppearance({ theme: 'dark' }); layer.setVisible(false); layer.setVisible(true); diff --git a/tests/browser/grid-style.spec.ts b/tests/browser/grid-style.spec.ts index 632b1a7..7d9b630 100644 --- a/tests/browser/grid-style.spec.ts +++ b/tests/browser/grid-style.spec.ts @@ -47,19 +47,15 @@ test('grid setters and drawing options update the rendered geometry of all four }; }; const initial = snapshot(); - layer.setStyle({ stroke: '#123456', majorWidth: 2, minorWidth: 0.5 }); + layer.setStyle({ line: { color: '#123456', majorWidth: 2, minorWidth: 0.5 } }); const updated = snapshot(); layer.setStyle({ - stroke: '#654321', - majorWidth: 3, - minorWidth: 0.75, - textColor: '#abcdef', - textFontFamily: 'sans-serif', - textFontSize: 9, + line: { color: '#654321', majorWidth: 3, minorWidth: 0.75 }, + text: { color: '#abcdef', fontFamily: 'sans-serif', fontSize: 9 }, }); const options = snapshot(); // Individual setters must still work after applying a complete options object. - layer.setStyle({ stroke: '#123456', majorWidth: 2, minorWidth: 0.5 }); + layer.setStyle({ line: { color: '#123456', majorWidth: 2, minorWidth: 0.5 } }); return { initial, updated, options, updatedAgain: snapshot() }; }); }); @@ -170,14 +166,15 @@ test('numeric layer styles reject invalid lengths before changing any style', as grid: { detail: 'detailed' }, }); chart.draw('#chart'); - chart.layers.resistance.setStyle({ stroke: 'blue', majorWidth: 2 }); - chart.layers.q.setStyle({ stroke: 'blue', strokeWidth: 2 }); + chart.layers.resistance.setStyle({ line: { color: 'blue', majorWidth: 2 } }); + chart.layers.q.setStyle({ line: { color: 'blue', width: 2 } }); const before = document.querySelector('svg')!.outerHTML; let rejected = 0; for (const width of [0, -1, NaN, Infinity, '3']) { for (const apply of [ - () => chart.layers.resistance.setStyle({ stroke: 'red', majorWidth: width as number }), - () => chart.layers.q.setStyle({ stroke: 'red', strokeWidth: width as number }), + () => + chart.layers.resistance.setStyle({ line: { color: 'red', majorWidth: width as number } }), + () => chart.layers.q.setStyle({ line: { color: 'red', width: width as number } }), ]) { try { apply(); diff --git a/tests/browser/label-layering.spec.ts b/tests/browser/label-layering.spec.ts index a054ea2..1ddbf42 100644 --- a/tests/browser/label-layering.spec.ts +++ b/tests/browser/label-layering.spec.ts @@ -24,7 +24,7 @@ test('grid and scale labels stay above geometry after updates, with markers on t const labels = document.querySelector(`[data-label-layer=${name}]`)!; layer.setVisible(false); const hidden = labels.getAttribute('opacity'); - layer.setStyle({ textColor: '#123456', textFontSize: 8 }); + layer.setStyle({ text: { color: '#123456', fontSize: 8 } }); layer.setDetail('standard'); layer.setVisible(true); const shown = labels.getAttribute('opacity'); diff --git a/tests/browser/svg-export.spec.ts b/tests/browser/svg-export.spec.ts index 677766a..01d91b6 100644 --- a/tests/browser/svg-export.spec.ts +++ b/tests/browser/svg-export.spec.ts @@ -333,7 +333,7 @@ test('standalone SVG paints the same chart as the live view', async ({ page }, t grid: { detail: 'detailed' }, }); chart.draw('#chart'); - chart.layers.resistance.setStyle({ textColor: '#ab1234', textFontSize: 8 }); + chart.layers.resistance.setStyle({ text: { color: '#ab1234', fontSize: 8 } }); chart.addMarker( chart.addTrace( [ diff --git a/tests/theme.test.ts b/tests/theme.test.ts index 4b0d4b4..0de7121 100644 --- a/tests/theme.test.ts +++ b/tests/theme.test.ts @@ -3,22 +3,45 @@ import { Theme } from '../src/appearance/Theme'; import type { SmithAppearance } from '../src'; describe('appearance resolution', () => { + it('copies nested overrides and resolves independent visual groups', () => { + const overrides = { + grid: { text: { color: '#123456' } }, + circles: { q: { width: 3 }, vswr: { color: 'purple' } }, + scales: { value: { fontSize: 14 }, indicator: { color: 'cyan' } }, + marker: { focus: { color: 'yellow' } }, + }; + const theme = Theme.resolve({ overrides }); + overrides.grid.text.color = 'red'; + overrides.circles.q.width = 8; + expect(theme.grid.text).toEqual({ color: '#123456', fontSize: 7 }); + expect(theme.circles).toEqual({ + q: { color: 'blue', width: 3 }, + vswr: { color: 'purple', width: 1 }, + }); + expect(theme.scales.value).toEqual({ color: '#b42318', fontSize: 14 }); + expect(theme.scales.indicator).toEqual({ color: 'cyan', outline: 'white' }); + expect(theme.marker.focus).toEqual({ color: 'yellow', halo: 'white' }); + }); it('merges partial overrides without mutating the input or sharing presets', () => { const colors = ['#123456', 'red']; const appearance: SmithAppearance = { theme: 'dark', - overrides: { traceColors: colors, grid: { majorWidth: 2 }, fontFamily: 'Arial' }, + overrides: { + traces: { colors: colors }, + grid: { line: { majorWidth: 2 } }, + text: { fontFamily: 'Arial' }, + }, }; const theme = Theme.resolve(appearance); expect(theme.background).toBe('#0f172a'); - expect(theme.grid.majorWidth).toBe(2); - expect(theme.grid.minorWidth).toBe(0.6); - expect(theme.fontFamily).toBe('Arial'); + expect(theme.grid.line.majorWidth).toBe(2); + expect(theme.grid.line.minorWidth).toBe(0.6); + expect(theme.text.fontFamily).toBe('Arial'); colors[0] = 'blue'; - theme.grid.stroke = 'pink'; - expect(theme.traceColors[0]).toBe('#123456'); - expect(Theme.resolve({ theme: 'dark' }).grid.stroke).toBe('#94a3b8'); - expect(appearance.overrides!.grid).toEqual({ majorWidth: 2 }); + theme.grid.line.color = 'pink'; + expect(theme.traces.colors[0]).toBe('#123456'); + expect(Theme.resolve({ theme: 'dark' }).grid.line.color).toBe('#94a3b8'); + expect(appearance.overrides!.grid).toEqual({ line: { majorWidth: 2 } }); expect(Theme.resolve().background).toBe('transparent'); }); @@ -28,14 +51,18 @@ describe('appearance resolution', () => { { theme: null }, { overrides: null }, { overrides: { grid: null } }, - { overrides: { grid: { majorWidth: 0 } } }, - { overrides: { scales: { fontSize: NaN } } }, - { overrides: { cursor: { lineWidth: Infinity } } }, - { overrides: { marker: { textColor: 'url(https://example.com)' } } }, + { overrides: { grid: { text: null } } }, + { overrides: { grid: { text: { unknown: 'red' } } } }, + { overrides: { grid: { textColor: 'red' } } }, + { overrides: { circles: { q: { width: 0 } } } }, + { overrides: { grid: { line: { majorWidth: 0 } } } }, + { overrides: { scales: { text: { fontSize: NaN } } } }, + { overrides: { cursor: { line: { width: Infinity } } } }, + { overrides: { marker: { text: { color: 'url(https://example.com)' } } } }, { overrides: { background: 'var(--external)' } }, - { overrides: { traceColors: [] } }, - { overrides: { traceColors: ['red', 'invalid'] } }, - { overrides: { fontFamily: '' } }, + { overrides: { traces: { colors: [] } } }, + { overrides: { traces: { colors: ['red', 'invalid'] } } }, + { overrides: { text: { fontFamily: '' } } }, { overrides: { grid: { missing: 'red' } } }, ])('rejects invalid configuration: %j', (appearance) => { expect(() => Theme.resolve(appearance as unknown as SmithAppearance)).toThrow();