smithkit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +176 -6
  2. package/dist/lib/Smith.d.ts +13 -37
  3. package/dist/lib/Smith.js +82 -282
  4. package/dist/lib/appearance/SvgTheme.d.ts +6 -0
  5. package/dist/lib/appearance/SvgTheme.js +21 -0
  6. package/dist/lib/appearance/Theme.d.ts +8 -0
  7. package/dist/lib/appearance/Theme.js +120 -0
  8. package/dist/lib/appearance/types.d.ts +55 -0
  9. package/dist/lib/appearance/types.js +1 -0
  10. package/dist/lib/grid/ConstCircles.d.ts +0 -3
  11. package/dist/lib/grid/ConstCircles.js +6 -20
  12. package/dist/lib/grid/ConstQCircles.js +1 -1
  13. package/dist/lib/grid/ConstSwrCircles.js +1 -1
  14. package/dist/lib/grid/GridDefinitions.d.ts +7 -1
  15. package/dist/lib/grid/GridDefinitions.js +29 -0
  16. package/dist/lib/grid/GridLayerControl.d.ts +5 -4
  17. package/dist/lib/grid/GridLayerControl.js +11 -2
  18. package/dist/lib/grid/SmithGridLayer.d.ts +7 -1
  19. package/dist/lib/grid/SmithGridLayer.js +35 -5
  20. package/dist/lib/index.d.ts +3 -1
  21. package/dist/lib/interaction/SmithCursor.d.ts +0 -17
  22. package/dist/lib/interaction/SmithCursor.js +6 -14
  23. package/dist/lib/layers.d.ts +5 -1
  24. package/dist/lib/rendering/SvgChartRenderer.d.ts +66 -0
  25. package/dist/lib/rendering/SvgChartRenderer.js +342 -0
  26. package/dist/lib/scales/SmithPeripheralScales.js +11 -11
  27. package/dist/lib/scales/SmithScales.d.ts +10 -3
  28. package/dist/lib/scales/SmithScales.js +73 -39
  29. package/dist/lib/svg/ImageExporter.d.ts +18 -0
  30. package/dist/lib/svg/ImageExporter.js +211 -0
  31. package/dist/lib/svg/MarkerLegend.d.ts +12 -0
  32. package/dist/lib/svg/MarkerLegend.js +63 -0
  33. package/dist/lib/svg/SvgExporter.d.ts +6 -1
  34. package/dist/lib/svg/SvgExporter.js +50 -5
  35. package/dist/lib/svg/export.d.ts +36 -0
  36. package/dist/lib/svg/export.js +1 -0
  37. package/dist/lib/traces/SmithMarker.js +8 -5
  38. package/package.json +1 -1
package/dist/lib/Smith.js CHANGED
@@ -1,25 +1,9 @@
1
- import * as d3 from 'd3';
2
- import { MouseGesture } from './interaction/MouseGesture.js';
3
- import { SmithSvg } from './svg/SmithSvg.js';
4
- import { LabelLayout } from './svg/LabelLayout.js';
5
- import { SvgExporter } from './svg/SvgExporter.js';
6
- import { SmithGroup } from './svg/SmithGroup.js';
7
- import { SmithCircle } from './svg/SmithCircle.js';
8
- import { SmithData } from './traces/SmithData.js';
9
- import { SmithCursor } from './interaction/SmithCursor.js';
10
- import { ConstResistance } from './grid/ConstResistance.js';
11
- import { ConstReactance } from './grid/ConstReactance.js';
12
- import { ConstConductance } from './grid/ConstConductance.js';
13
- import { ConstSusceptance } from './grid/ConstSusceptance.js';
14
- import { ConstQCircles } from './grid/ConstQCircles.js';
15
- import { ConstSwrCircles } from './grid/ConstSwrCircles.js';
16
- import { SmithScaler } from './svg/SmithScaler.js';
17
- import { SmithConstantCircle } from './rf/SmithConstantCircle.js';
18
- import { GridDefinitions } from './grid/GridDefinitions.js';
1
+ import { MarkerLegend } from './svg/MarkerLegend.js';
2
+ import { ImageExporter } from './svg/ImageExporter.js';
3
+ import { Theme } from './appearance/Theme.js';
4
+ import { color as parseColor } from 'd3';
5
+ import { SvgChartRenderer } from './rendering/SvgChartRenderer.js';
19
6
  import { Complex } from './math/Complex.js';
20
- import { SmithPeripheralScales } from './scales/SmithPeripheralScales.js';
21
- import { GridLayerControl } from './grid/GridLayerControl.js';
22
- import { CircleLayerControl } from './grid/CircleLayerControl.js';
23
7
  import { RfCalculations } from './rf/RfCalculations.js';
24
8
  import { MarkerMeasurements } from './MarkerMeasurements.js';
25
9
  export var SmithEventType;
@@ -32,206 +16,101 @@ export var SmithEventType;
32
16
  })(SmithEventType || (SmithEventType = {}));
33
17
  export class Smith {
34
18
  referenceOhms;
35
- calcs = new SmithConstantCircle();
36
- scalers;
37
- defaultTransform = d3.zoomIdentity.translate(62.5, 62.5).scale(0.75);
38
- transform = d3.zoomIdentity;
39
- zoomEnabled = true;
40
- applyingView = false;
41
- zoomBehavior = d3.zoom();
42
- svg;
43
- resizeObserver;
44
- labelLayout;
45
- container;
46
- dataContainer;
47
- markerContainer;
48
- reactanceAxis;
49
- constResistance;
50
- constReactance;
51
- constConductance;
52
- constSusceptance;
53
- constSwrCircles;
54
- constQCircles;
55
- cursor;
56
- peripheralScaleRenderer = new SmithPeripheralScales();
19
+ theme;
20
+ renderer;
57
21
  data = [];
58
22
  destroyed = false;
59
- mouseGesture = new MouseGesture();
60
23
  nextDatasetColor = 0;
61
24
  nextTraceId = 1;
62
25
  nextMarkerId = 1;
63
26
  traceMetadata = new WeakMap();
64
27
  markerIds = new WeakMap();
65
28
  draggedMarkers = new Set();
66
- cursorBeforeMarkerDrag = null;
67
29
  listeners = new Set();
68
30
  layers;
69
31
  peripheralScales;
70
- constructor(referenceOhms = 50) {
32
+ constructor(referenceOhms = 50, appearance = {}) {
71
33
  this.referenceOhms = referenceOhms;
72
34
  if (!Number.isFinite(referenceOhms) || referenceOhms <= 0) {
73
35
  throw new RangeError('Reference impedance must be positive and finite.');
74
36
  }
75
- const viewBoxSize = 500;
76
- const gridData = GridDefinitions.create();
77
- this.scalers = this.createScalers(viewBoxSize);
78
- this.svg = new SmithSvg(viewBoxSize);
79
- this.container = new SmithGroup();
80
- this.constResistance = new ConstResistance({
81
- data: gridData,
82
- scaler: this.scalers.default,
83
- showMinor: true,
37
+ this.theme = Theme.resolve(appearance);
38
+ this.renderer = new SvgChartRenderer((position) => {
39
+ this.emit({
40
+ type: SmithEventType.Cursor,
41
+ data: position ? RfCalculations.readReflection(position, this.referenceOhms) : undefined,
42
+ });
84
43
  });
85
- this.constResistance.show();
86
- this.constReactance = new ConstReactance({
87
- data: gridData,
88
- scaler: this.scalers.default,
89
- showMinor: true,
90
- });
91
- this.constReactance.show();
92
- this.constConductance = new ConstConductance({
93
- data: gridData,
94
- scaler: this.scalers.default,
95
- showMinor: true,
96
- });
97
- this.constConductance.hide();
98
- this.constSusceptance = new ConstSusceptance({
99
- data: gridData,
100
- scaler: this.scalers.default,
101
- showMinor: true,
102
- });
103
- this.constSusceptance.hide();
104
- this.constQCircles = new ConstQCircles(this.scalers.default);
105
- this.constQCircles.hide();
106
- this.constSwrCircles = new ConstSwrCircles(this.scalers.default);
107
- this.constSwrCircles.hide();
108
- this.cursor = this.initCursor();
109
- const cursorContainer = this.cursorContainer();
110
- this.reactanceAxis = this.drawReactanceAxis({
111
- stroke: '#334155',
112
- strokeWidth: '1',
113
- fill: 'none',
114
- });
115
- this.dataContainer = new SmithGroup().attr('data-layer', 'samples');
116
- this.markerContainer = new SmithGroup().attr('data-layer', 'markers');
117
- // build chart
118
- this.svg.append(this.container);
119
- this.container.append(this.constConductance.draw().attr('data-layer', 'conductance'));
120
- this.container.append(this.constSusceptance.draw().attr('data-layer', 'susceptance'));
121
- this.container.append(this.constResistance.draw().attr('data-layer', 'resistance'));
122
- this.container.append(this.constReactance.draw().attr('data-layer', 'reactance'));
123
- this.container.append(this.constQCircles.draw());
124
- this.container.append(this.constSwrCircles.draw());
125
- this.container.append(this.peripheralScaleRenderer);
126
- this.container.append(this.cursor.Group);
127
- this.container.append(this.reactanceAxis);
128
- this.container.append(cursorContainer);
129
- this.container.append(this.dataContainer);
130
- const labels = new SmithGroup().attr('data-layer', 'labels').attr('pointer-events', 'none');
131
- for (const [name, layer] of [
132
- ['conductance', this.constConductance],
133
- ['susceptance', this.constSusceptance],
134
- ['resistance', this.constResistance],
135
- ['reactance', this.constReactance],
136
- ]) {
137
- labels.append(layer.labels.attr('data-label-layer', name));
44
+ this.renderer.setTheme(this.theme);
45
+ this.layers = this.renderer.layers;
46
+ this.peripheralScales = this.renderer.peripheralScales;
47
+ }
48
+ /** Replace the appearance while retaining explicit layer styles and trace colors. */
49
+ setAppearance(appearance) {
50
+ this.assertAlive();
51
+ const theme = Theme.resolve(appearance);
52
+ this.theme = theme;
53
+ this.renderer.setTheme(theme);
54
+ for (const data of this.data) {
55
+ const index = this.traceMetadata.get(data).colorIndex;
56
+ if (index !== undefined) {
57
+ data.setColor(theme.traceColors[index % theme.traceColors.length]);
58
+ }
138
59
  }
139
- labels.append(this.peripheralScaleRenderer.labels);
140
- this.container.append(labels);
141
- this.container.append(this.markerContainer);
142
- this.labelLayout = new LabelLayout(this.svg.Node, labels.Node, this.defaultTransform);
143
- const assertAlive = () => this.assertAlive();
144
- this.layers = {
145
- resistance: new GridLayerControl(this.constResistance, assertAlive, () => this.labelLayout.update(true)),
146
- reactance: new GridLayerControl(this.constReactance, assertAlive, () => this.labelLayout.update(true)),
147
- conductance: new GridLayerControl(this.constConductance, assertAlive, () => this.labelLayout.update(true)),
148
- susceptance: new GridLayerControl(this.constSusceptance, assertAlive, () => this.labelLayout.update(true)),
149
- q: new CircleLayerControl(this.constQCircles, 0, assertAlive),
150
- vswr: new CircleLayerControl(this.constSwrCircles, 1, assertAlive),
151
- };
152
- this.peripheralScales = {
153
- setVisible: (visible) => {
154
- assertAlive();
155
- if (visible) {
156
- this.peripheralScaleRenderer.show();
157
- }
158
- else {
159
- this.peripheralScaleRenderer.hide();
160
- }
161
- this.labelLayout.update(true);
162
- },
163
- update: (gamma) => {
164
- assertAlive();
165
- this.peripheralScaleRenderer.update(gamma);
166
- },
167
- };
168
- this.initializeZoom();
169
- this.resizeObserver = new ResizeObserver(() => this.updateViewportScale());
170
- this.resizeObserver.observe(this.svg.Node);
171
60
  }
172
61
  draw(target) {
173
62
  this.assertAlive();
174
- const host = typeof target === 'string' ? document.querySelector(target) : target;
175
- if (!host) {
176
- throw new Error('Chart container was not found.');
177
- }
178
- host.appendChild(this.svg.Node);
179
- this.updateViewportScale();
63
+ this.renderer.draw(target);
180
64
  }
181
- /** Export the current mounted view as standalone SVG with a transparent background. */
182
- toSvg() {
65
+ /** Export the current mounted view as standalone SVG with its configured background. */
66
+ toSvg(options) {
183
67
  this.assertAlive();
184
- return SvgExporter.chart(this.svg.Node);
68
+ const source = this.renderer.toSvg();
69
+ if (options === undefined) {
70
+ return source;
71
+ }
72
+ const sources = [source];
73
+ if (options.scales) {
74
+ sources.push(options.scales.toSvg({ readout: options.scaleReadout }));
75
+ }
76
+ return ImageExporter.svg(sources, options, this.exportLegend(options));
77
+ }
78
+ exportLegend(options) {
79
+ const traces = this.getTraces();
80
+ return {
81
+ entries: [
82
+ ...(options.legend ? traces.filter((trace) => trace.visible) : []),
83
+ ...(options.markerLegend
84
+ ? MarkerLegend.entries(traces, (id) => this.getMarker(id), options.markerLegend)
85
+ : []),
86
+ ],
87
+ textColor: this.theme.grid.textColor,
88
+ fontFamily: this.theme.fontFamily,
89
+ };
90
+ }
91
+ /** Export the mounted view as PNG, optionally with radial scales and a trace legend. */
92
+ async toPng(options = {}) {
93
+ const sources = [this.toSvg()];
94
+ if (options.scales) {
95
+ sources.push(options.scales.toSvg({ readout: options.scaleReadout }));
96
+ }
97
+ return ImageExporter.png(sources, options, this.exportLegend(options));
185
98
  }
186
99
  /** Remove this chart and release its event handlers. Safe to call more than once. */
187
100
  destroy() {
188
101
  if (this.destroyed) {
189
102
  return;
190
103
  }
191
- this.resizeObserver.disconnect();
192
104
  this.listeners.clear();
193
105
  this.clearTraces();
194
106
  this.destroyed = true;
195
- this.cursor.setMoveHandler(null);
196
- this.mouseGesture.destroy();
197
- this.zoomBehavior.on('start', null).on('zoom', null);
198
- this.svg.Element.interrupt().on('.zoom', null);
199
- this.svg.Element.selectAll('*').interrupt().on('.smithkit', null).on('.drag', null);
200
- this.svg.Element.remove();
201
- }
202
- updateViewportScale() {
203
- if (this.destroyed) {
204
- return;
205
- }
206
- const matrix = this.svg.Node.getScreenCTM();
207
- if (matrix) {
208
- const scale = Math.hypot(matrix.a, matrix.b);
209
- this.data.forEach((trace) => trace.setViewportScale(scale));
210
- }
211
- this.labelLayout.update();
107
+ this.renderer.destroy();
212
108
  }
213
109
  assertAlive() {
214
110
  if (this.destroyed) {
215
111
  throw new Error('This Smith chart has been destroyed. Create a new instance.');
216
112
  }
217
113
  }
218
- createScalers(size) {
219
- const impedance = new SmithScaler(d3.scaleLinear().domain([-1, 1]).range([0, size]), d3.scaleLinear().domain([1, -1]).range([0, size]), d3
220
- .scaleLinear()
221
- .domain([0, 1])
222
- .range([0, size / 2]));
223
- const admittance = new SmithScaler(d3.scaleLinear().domain([1, -1]).range([0, size]), d3.scaleLinear().domain([-1, 1]).range([0, size]), d3
224
- .scaleLinear()
225
- .domain([0, 1])
226
- .range([0, size / 2]));
227
- return { default: impedance, impedance, admittance };
228
- }
229
- cursorMove(p) {
230
- if (this.draggedMarkers.size > 0) {
231
- return;
232
- }
233
- this.cursor.Position = Complex.from(this.scalers.default.pointInvert(p));
234
- }
235
114
  markerDragChanged(marker, dragging) {
236
115
  const wasDragging = this.draggedMarkers.size > 0;
237
116
  if (dragging) {
@@ -244,107 +123,23 @@ export class Smith {
244
123
  if (isDragging === wasDragging) {
245
124
  return;
246
125
  }
126
+ this.renderer.setMarkerDragging(isDragging);
247
127
  if (isDragging) {
248
- this.cursorBeforeMarkerDrag = this.svg.Node.style.getPropertyValue('cursor') || null;
249
- this.svg.Element.style('cursor', 'grabbing').style('--smithkit-marker-cursor', 'grabbing');
250
- this.cursor.hide();
251
128
  this.emit({ type: SmithEventType.Cursor, data: undefined });
252
129
  }
253
- else {
254
- this.svg.Element.style('cursor', () => this.cursorBeforeMarkerDrag).style('--smithkit-marker-cursor', null);
255
- this.cursorBeforeMarkerDrag = null;
256
- }
257
- }
258
- initCursor() {
259
- const cursor = new SmithCursor(this.scalers.default);
260
- cursor.Group.attr('class', 'smith-cursor');
261
- cursor.setMoveHandler(() => {
262
- this.emit({ type: SmithEventType.Cursor, data: this.cursorReading });
263
- });
264
- return cursor;
265
130
  }
266
131
  /** Last cursor position. Use cursor events to detect pointer leave. */
267
132
  get cursorReading() {
268
- return RfCalculations.readReflection(this.cursor.Position, this.referenceImpedanceOhms);
269
- }
270
- initializeZoom() {
271
- const zoom = this.zoomBehavior
272
- .scaleExtent([0.6, 1000])
273
- .filter((event) => this.zoomEnabled &&
274
- this.draggedMarkers.size === 0 &&
275
- (!event.ctrlKey || event.type === 'wheel') &&
276
- !event.button)
277
- .on('start', (event) => {
278
- if (event.sourceEvent) {
279
- this.mouseGesture.capture(event.sourceEvent, 'zoom');
280
- }
281
- })
282
- .on('zoom', (event) => {
283
- if ((!this.zoomEnabled || this.draggedMarkers.size > 0) && !this.applyingView) {
284
- // An already active mouse/touch gesture still needs its normal end event.
285
- // Restore D3's view through its public API while that gesture finishes.
286
- this.applyView(this.transform);
287
- return;
288
- }
289
- this.onZoom(event.transform);
290
- });
291
- this.svg.Element.call(zoom);
292
- this.resetView();
133
+ return RfCalculations.readReflection(this.renderer.cursorPosition, this.referenceOhms);
293
134
  }
294
135
  /** Enable or disable wheel, double-click, and mouse/touch zoom/pan. Keeps the current view. */
295
136
  setZoomEnabled(enabled) {
296
137
  this.assertAlive();
297
- if (typeof enabled !== 'boolean') {
298
- throw new TypeError('Zoom enabled must be a boolean.');
299
- }
300
- if (this.zoomEnabled === enabled) {
301
- return;
302
- }
303
- this.zoomEnabled = enabled;
304
- if (!enabled) {
305
- this.svg.Element.interrupt();
306
- }
307
- }
308
- applyView(transform) {
309
- this.applyingView = true;
310
- try {
311
- this.svg.Element.call(this.zoomBehavior.transform, transform);
312
- }
313
- finally {
314
- this.applyingView = false;
315
- }
138
+ this.renderer.setZoomEnabled(enabled);
316
139
  }
317
140
  resetView() {
318
141
  this.assertAlive();
319
- const transform = this.defaultTransform;
320
- this.applyView(transform);
321
- }
322
- onZoom(transform) {
323
- if (this.destroyed) {
324
- return;
325
- }
326
- this.transform = transform;
327
- this.container.Element.attr('transform', transform.toString());
328
- this.data.forEach((d) => d.zoom(transform));
329
- }
330
- cursorContainer() {
331
- const shape = this.drawReactanceAxis({ fill: 'transparent', stroke: 'none' });
332
- shape.Element.style('pointer-events', 'all')
333
- .on('pointermove.smithkit', (event) => {
334
- this.cursorMove(d3.pointer(event));
335
- })
336
- .on('pointerleave.smithkit', () => {
337
- this.cursor.hide();
338
- this.emit({ type: SmithEventType.Cursor, data: undefined });
339
- });
340
- return shape;
341
- }
342
- drawReactanceAxis(opts) {
343
- const c = this.calcs.resistanceCircle(0);
344
- c.p[0] = this.scalers.default.x(c.p[0]);
345
- c.p[1] = this.scalers.default.y(c.p[1]);
346
- c.r = this.scalers.default.r(c.r);
347
- return new SmithCircle(c, opts);
142
+ this.renderer.resetView();
348
143
  }
349
144
  /** Add a named trace with one initial marker. Returns a chart-local, stable ID. */
350
145
  addTrace(values, options = {}) {
@@ -356,7 +151,6 @@ export class Smith {
356
151
  const data = this.createSmithData(values, this.nextDatasetColor, options);
357
152
  this.nextDatasetColor++;
358
153
  this.data.push(data);
359
- this.updateViewportScale();
360
154
  const id = this.traceMetadata.get(data).id;
361
155
  this.setTraceOptions(id, options);
362
156
  return id;
@@ -364,7 +158,8 @@ export class Smith {
364
158
  /** Detached metadata snapshots; IDs and marker display numbers survive removals. */
365
159
  getTraces() {
366
160
  return this.data.map((data) => ({
367
- ...this.traceMetadata.get(data),
161
+ id: this.traceMetadata.get(data).id,
162
+ name: this.traceMetadata.get(data).name,
368
163
  ...data.Style,
369
164
  color: data.Color,
370
165
  visible: data.Visible,
@@ -410,7 +205,7 @@ export class Smith {
410
205
  throw new TypeError('Trace name must not be empty.');
411
206
  }
412
207
  if (options.color !== undefined &&
413
- (typeof options.color !== 'string' || !d3.color(options.color))) {
208
+ (typeof options.color !== 'string' || !parseColor(options.color))) {
414
209
  throw new TypeError('Trace color must be a solid CSS color.');
415
210
  }
416
211
  if (options.visible !== undefined && typeof options.visible !== 'boolean') {
@@ -431,6 +226,7 @@ export class Smith {
431
226
  }
432
227
  if (options.color !== undefined) {
433
228
  data.setColor(options.color);
229
+ delete this.traceMetadata.get(data).colorIndex;
434
230
  }
435
231
  if (options.visible !== undefined) {
436
232
  data.setVisible(options.visible);
@@ -457,7 +253,7 @@ export class Smith {
457
253
  if (index < 0) {
458
254
  return false;
459
255
  }
460
- this.data[index].destroy();
256
+ this.renderer.removeTrace(this.data[index]);
461
257
  this.data.splice(index, 1);
462
258
  return true;
463
259
  }
@@ -537,17 +333,17 @@ export class Smith {
537
333
  this.data.forEach((data) => data.Markers.forEach((entry) => entry.marker.cancelDrag()));
538
334
  this.referenceOhms = referenceImpedanceOhms;
539
335
  this.data.forEach((data, index) => data.update(samples[index], 'sample-index'));
540
- this.cursor.hide();
336
+ this.renderer.hideCursor();
541
337
  this.emit({ type: SmithEventType.Cursor, data: undefined });
542
338
  }
543
339
  clearTraces() {
544
340
  this.assertAlive();
545
- this.data.forEach((dataset) => dataset.destroy());
341
+ this.data.forEach((dataset) => this.renderer.removeTrace(dataset));
546
342
  this.data = [];
547
343
  }
548
344
  createSmithData(values, dataset, options) {
549
- const color = d3.schemeCategory10[(1 + dataset) % d3.schemeCategory10.length];
550
- const data = new SmithData(values, color, this.transform, this.dataContainer, this.scalers.default, (marker, dragging) => {
345
+ const color = this.theme.traceColors[dataset % this.theme.traceColors.length];
346
+ const data = this.renderer.createTrace(values, color, options, (marker, dragging) => {
551
347
  this.markerDragChanged(marker, dragging);
552
348
  const snapshot = this.getMarker(this.markerId(marker));
553
349
  if (snapshot) {
@@ -556,9 +352,13 @@ export class Smith {
556
352
  data: snapshot,
557
353
  });
558
354
  }
559
- }, this.markerContainer, options);
355
+ });
560
356
  const number = this.nextTraceId++;
561
- this.traceMetadata.set(data, { id: `trace-${number}`, name: `Trace ${number}` });
357
+ this.traceMetadata.set(data, {
358
+ id: `trace-${number}`,
359
+ name: `Trace ${number}`,
360
+ colorIndex: dataset,
361
+ });
562
362
  data.setName(`Trace ${number}`);
563
363
  data.setMarkerSelectHandler((marker) => {
564
364
  const snapshot = this.getMarker(this.markerId(marker));
@@ -0,0 +1,6 @@
1
+ import type { SmithTheme } from './types.js';
2
+ /** Scope presentation tokens to one chart or scale component. Explicit SVG styles take priority. */
3
+ export declare class SvgTheme {
4
+ private constructor();
5
+ static apply(element: HTMLElement | SVGElement, theme: SmithTheme): void;
6
+ }
@@ -0,0 +1,21 @@
1
+ /** Scope presentation tokens to one chart or scale component. Explicit SVG styles take priority. */
2
+ export class SvgTheme {
3
+ constructor() { }
4
+ static apply(element, theme) {
5
+ for (const [section, value] of Object.entries(theme)) {
6
+ if (Array.isArray(value)) {
7
+ continue;
8
+ }
9
+ if (typeof value === 'object') {
10
+ for (const [key, entry] of Object.entries(value)) {
11
+ const suffix = key.toLowerCase().endsWith('fontsize') ? 'px' : '';
12
+ element.style.setProperty(`--smithkit-${section}-${key}`, String(entry) + suffix);
13
+ }
14
+ }
15
+ else {
16
+ element.style.setProperty(`--smithkit-${section}`, value);
17
+ }
18
+ }
19
+ element.style.backgroundColor = theme.background;
20
+ }
21
+ }
@@ -0,0 +1,8 @@
1
+ import type { SmithAppearance, SmithTheme } from './types.js';
2
+ /** Resolve and validate a detached theme before touching any chart state or DOM. */
3
+ export declare class Theme {
4
+ private constructor();
5
+ static resolve(appearance?: SmithAppearance): SmithTheme;
6
+ private static merge;
7
+ private static isColor;
8
+ }
@@ -0,0 +1,120 @@
1
+ import { color } from 'd3';
2
+ /** Resolve and validate a detached theme before touching any chart state or DOM. */
3
+ export class Theme {
4
+ constructor() { }
5
+ static resolve(appearance = {}) {
6
+ if (!appearance || typeof appearance !== 'object' || Array.isArray(appearance)) {
7
+ throw new TypeError('Appearance must be an object.');
8
+ }
9
+ const name = appearance.theme === undefined ? 'light' : appearance.theme;
10
+ if (name !== 'light' && name !== 'dark') {
11
+ throw new TypeError('Theme must be light or dark.');
12
+ }
13
+ const dark = name === 'dark';
14
+ const theme = {
15
+ background: dark ? '#0f172a' : 'transparent',
16
+ fontFamily: 'Verdana, sans-serif',
17
+ grid: {
18
+ stroke: dark ? '#94a3b8' : '#64748b',
19
+ textColor: dark ? '#e2e8f0' : '#111827',
20
+ majorWidth: 1,
21
+ minorWidth: 0.6,
22
+ fontSize: 7,
23
+ },
24
+ boundary: { stroke: dark ? '#cbd5e1' : '#334155', width: 1 },
25
+ circles: { q: dark ? '#93c5fd' : 'blue', vswr: dark ? '#fbbf24' : 'orange', width: 1 },
26
+ scales: {
27
+ stroke: dark ? '#94a3b8' : '#64748b',
28
+ boundaryColor: dark ? '#cbd5e1' : '#334155',
29
+ textColor: dark ? '#e2e8f0' : '#334155',
30
+ valueColor: dark ? '#fca5a5' : '#b42318',
31
+ indicatorColor: dark ? '#f87171' : '#dc2626',
32
+ indicatorOutline: dark ? '#0f172a' : 'white',
33
+ fontSize: 10,
34
+ titleFontSize: 12,
35
+ valueFontSize: 11,
36
+ peripheralFontSize: 10.5,
37
+ },
38
+ cursor: {
39
+ pointColor: dark ? '#fca5a5' : 'red',
40
+ impedanceColor: dark ? '#fca5a5' : 'red',
41
+ admittanceColor: dark ? '#86efac' : 'green',
42
+ pointSize: 5,
43
+ lineWidth: 1,
44
+ },
45
+ marker: {
46
+ outlineColor: dark ? '#e2e8f0' : 'gray',
47
+ textColor: 'white',
48
+ focusColor: dark ? '#93c5fd' : '#1d4ed8',
49
+ focusHaloColor: dark ? '#0f172a' : 'white',
50
+ },
51
+ traceColors: dark
52
+ ? [
53
+ '#fb923c',
54
+ '#4ade80',
55
+ '#f87171',
56
+ '#c084fc',
57
+ '#d6a681',
58
+ '#f472b6',
59
+ '#cbd5e1',
60
+ '#d4d45c',
61
+ '#22d3ee',
62
+ '#60a5fa',
63
+ ]
64
+ : [
65
+ '#ff7f0e',
66
+ '#2ca02c',
67
+ '#d62728',
68
+ '#9467bd',
69
+ '#8c564b',
70
+ '#e377c2',
71
+ '#7f7f7f',
72
+ '#bcbd22',
73
+ '#17becf',
74
+ '#1f77b4',
75
+ ],
76
+ };
77
+ if (appearance.overrides !== undefined) {
78
+ Theme.merge(theme, appearance.overrides);
79
+ }
80
+ return theme;
81
+ }
82
+ static merge(target, source, path = 'appearance') {
83
+ if (!source || typeof source !== 'object' || Array.isArray(source)) {
84
+ throw new TypeError(`${path} overrides must be an object.`);
85
+ }
86
+ for (const [key, value] of Object.entries(source)) {
87
+ if (!Object.hasOwn(target, key)) {
88
+ throw new TypeError(`Unknown appearance setting: ${path}.${key}.`);
89
+ }
90
+ if (value === undefined) {
91
+ continue;
92
+ }
93
+ const previous = target[key];
94
+ if (Array.isArray(previous)) {
95
+ if (!Array.isArray(value) || value.length === 0 || !value.every(Theme.isColor)) {
96
+ throw new TypeError('Trace colors must be a non-empty array of solid CSS colors.');
97
+ }
98
+ target[key] = [...value];
99
+ }
100
+ else if (typeof previous === 'object') {
101
+ Theme.merge(previous, value, `${path}.${key}`);
102
+ }
103
+ else if (typeof previous === 'number') {
104
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
105
+ throw new RangeError(`${path}.${key} must be positive and finite.`);
106
+ }
107
+ target[key] = value;
108
+ }
109
+ else {
110
+ if (key === 'fontFamily' ? typeof value !== 'string' || !value.trim() : !Theme.isColor(value)) {
111
+ throw new TypeError(`${path}.${key} must be ${key === 'fontFamily' ? 'a non-empty font family' : 'a solid CSS color'}.`);
112
+ }
113
+ target[key] = value;
114
+ }
115
+ }
116
+ }
117
+ static isColor(value) {
118
+ return typeof value === 'string' && color(value) !== null;
119
+ }
120
+ }