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
@@ -0,0 +1,342 @@
1
+ import { SvgTheme } from '../appearance/SvgTheme.js';
2
+ import * as d3 from 'd3';
3
+ import { MouseGesture } from '../interaction/MouseGesture.js';
4
+ import { SmithSvg } from '../svg/SmithSvg.js';
5
+ import { LabelLayout } from '../svg/LabelLayout.js';
6
+ import { SvgExporter } from '../svg/SvgExporter.js';
7
+ import { SmithGroup } from '../svg/SmithGroup.js';
8
+ import { SmithCircle } from '../svg/SmithCircle.js';
9
+ import { SmithData } from '../traces/SmithData.js';
10
+ import { SmithCursor } from '../interaction/SmithCursor.js';
11
+ import { ConstResistance } from '../grid/ConstResistance.js';
12
+ import { ConstReactance } from '../grid/ConstReactance.js';
13
+ import { ConstConductance } from '../grid/ConstConductance.js';
14
+ import { ConstSusceptance } from '../grid/ConstSusceptance.js';
15
+ import { ConstQCircles } from '../grid/ConstQCircles.js';
16
+ import { ConstSwrCircles } from '../grid/ConstSwrCircles.js';
17
+ import { SmithScaler } from '../svg/SmithScaler.js';
18
+ import { SmithConstantCircle } from '../rf/SmithConstantCircle.js';
19
+ import { GridDefinitions } from '../grid/GridDefinitions.js';
20
+ import { Complex } from '../math/Complex.js';
21
+ import { SmithPeripheralScales } from '../scales/SmithPeripheralScales.js';
22
+ import { GridLayerControl } from '../grid/GridLayerControl.js';
23
+ import { CircleLayerControl } from '../grid/CircleLayerControl.js';
24
+ /** Internal SVG backend. Owns chart elements, view transforms, and SVG gestures. */
25
+ export class SvgChartRenderer {
26
+ onCursorChange;
27
+ calcs = new SmithConstantCircle();
28
+ scalers;
29
+ defaultTransform = d3.zoomIdentity.translate(62.5, 62.5).scale(0.75);
30
+ transform = d3.zoomIdentity;
31
+ zoomEnabled = true;
32
+ applyingView = false;
33
+ zoomBehavior = d3.zoom();
34
+ svg;
35
+ resizeObserver;
36
+ labelLayout;
37
+ container;
38
+ dataContainer;
39
+ markerContainer;
40
+ reactanceAxis;
41
+ constResistance;
42
+ constReactance;
43
+ constConductance;
44
+ constSusceptance;
45
+ constSwrCircles;
46
+ constQCircles;
47
+ cursor;
48
+ peripheralScaleRenderer = new SmithPeripheralScales();
49
+ traces = new Set();
50
+ destroyed = false;
51
+ mouseGesture = new MouseGesture();
52
+ markerDragging = false;
53
+ cursorBeforeMarkerDrag = null;
54
+ layers;
55
+ peripheralScales;
56
+ constructor(onCursorChange) {
57
+ this.onCursorChange = onCursorChange;
58
+ const viewBoxSize = 500;
59
+ const gridData = GridDefinitions.create();
60
+ this.scalers = this.createScalers(viewBoxSize);
61
+ this.svg = new SmithSvg(viewBoxSize);
62
+ this.container = new SmithGroup();
63
+ this.constResistance = new ConstResistance({
64
+ data: gridData,
65
+ scaler: this.scalers.default,
66
+ detail: 'detailed',
67
+ });
68
+ this.constResistance.show();
69
+ this.constReactance = new ConstReactance({
70
+ data: gridData,
71
+ scaler: this.scalers.default,
72
+ detail: 'detailed',
73
+ });
74
+ this.constReactance.show();
75
+ this.constConductance = new ConstConductance({
76
+ data: gridData,
77
+ scaler: this.scalers.default,
78
+ detail: 'detailed',
79
+ });
80
+ this.constConductance.hide();
81
+ this.constSusceptance = new ConstSusceptance({
82
+ data: gridData,
83
+ scaler: this.scalers.default,
84
+ detail: 'detailed',
85
+ });
86
+ this.constSusceptance.hide();
87
+ this.constQCircles = new ConstQCircles(this.scalers.default);
88
+ this.constQCircles.hide();
89
+ this.constSwrCircles = new ConstSwrCircles(this.scalers.default);
90
+ this.constSwrCircles.hide();
91
+ this.cursor = this.initCursor();
92
+ const cursorContainer = this.cursorContainer();
93
+ this.reactanceAxis = this.drawReactanceAxis({
94
+ stroke: 'var(--smithkit-boundary-stroke)',
95
+ strokeWidth: 'var(--smithkit-boundary-width)',
96
+ fill: 'none',
97
+ });
98
+ this.dataContainer = new SmithGroup().attr('data-layer', 'samples');
99
+ this.markerContainer = new SmithGroup().attr('data-layer', 'markers');
100
+ // build chart
101
+ this.svg.append(this.container);
102
+ this.container.append(this.constConductance.draw().attr('data-layer', 'conductance'));
103
+ this.container.append(this.constSusceptance.draw().attr('data-layer', 'susceptance'));
104
+ this.container.append(this.constResistance.draw().attr('data-layer', 'resistance'));
105
+ this.container.append(this.constReactance.draw().attr('data-layer', 'reactance'));
106
+ this.container.append(this.constQCircles.draw());
107
+ this.container.append(this.constSwrCircles.draw());
108
+ this.container.append(this.peripheralScaleRenderer);
109
+ this.container.append(this.cursor.Group);
110
+ this.container.append(this.reactanceAxis);
111
+ this.container.append(cursorContainer);
112
+ this.container.append(this.dataContainer);
113
+ const labels = new SmithGroup().attr('data-layer', 'labels').attr('pointer-events', 'none');
114
+ for (const [name, layer] of [
115
+ ['conductance', this.constConductance],
116
+ ['susceptance', this.constSusceptance],
117
+ ['resistance', this.constResistance],
118
+ ['reactance', this.constReactance],
119
+ ]) {
120
+ labels.append(layer.labels.attr('data-label-layer', name));
121
+ }
122
+ labels.append(this.peripheralScaleRenderer.labels);
123
+ this.container.append(labels);
124
+ this.container.append(this.markerContainer);
125
+ this.labelLayout = new LabelLayout(this.svg.Node, labels.Node, this.defaultTransform);
126
+ const assertAlive = () => this.assertAlive();
127
+ this.layers = {
128
+ resistance: new GridLayerControl(this.constResistance, assertAlive, () => this.labelLayout.update(true)),
129
+ reactance: new GridLayerControl(this.constReactance, assertAlive, () => this.labelLayout.update(true)),
130
+ conductance: new GridLayerControl(this.constConductance, assertAlive, () => this.labelLayout.update(true)),
131
+ susceptance: new GridLayerControl(this.constSusceptance, assertAlive, () => this.labelLayout.update(true)),
132
+ q: new CircleLayerControl(this.constQCircles, 0, assertAlive),
133
+ vswr: new CircleLayerControl(this.constSwrCircles, 1, assertAlive),
134
+ };
135
+ this.peripheralScales = {
136
+ setVisible: (visible) => {
137
+ assertAlive();
138
+ if (visible) {
139
+ this.peripheralScaleRenderer.show();
140
+ }
141
+ else {
142
+ this.peripheralScaleRenderer.hide();
143
+ }
144
+ this.labelLayout.update(true);
145
+ },
146
+ update: (gamma) => {
147
+ assertAlive();
148
+ this.peripheralScaleRenderer.update(gamma);
149
+ },
150
+ };
151
+ this.initializeZoom();
152
+ this.resizeObserver = new ResizeObserver(() => this.updateViewportScale());
153
+ this.resizeObserver.observe(this.svg.Node);
154
+ }
155
+ setTheme(theme) {
156
+ this.assertAlive();
157
+ SvgTheme.apply(this.svg.Node, theme);
158
+ this.labelLayout.update(true);
159
+ }
160
+ draw(target) {
161
+ this.assertAlive();
162
+ const host = typeof target === 'string' ? document.querySelector(target) : target;
163
+ if (!host) {
164
+ throw new Error('Chart container was not found.');
165
+ }
166
+ host.appendChild(this.svg.Node);
167
+ this.updateViewportScale();
168
+ }
169
+ /** Export the current mounted view as standalone SVG with its configured background. */
170
+ toSvg() {
171
+ this.assertAlive();
172
+ return SvgExporter.chart(this.svg.Node);
173
+ }
174
+ /** Remove this chart and release its event handlers. Safe to call more than once. */
175
+ destroy() {
176
+ if (this.destroyed) {
177
+ return;
178
+ }
179
+ this.resizeObserver.disconnect();
180
+ this.traces.forEach((trace) => trace.destroy());
181
+ this.traces.clear();
182
+ this.destroyed = true;
183
+ this.cursor.setMoveHandler(null);
184
+ this.onCursorChange = null;
185
+ this.mouseGesture.destroy();
186
+ this.zoomBehavior.on('start', null).on('zoom', null);
187
+ this.svg.Element.interrupt().on('.zoom', null);
188
+ this.svg.Element.selectAll('*').interrupt().on('.smithkit', null).on('.drag', null);
189
+ this.svg.Element.remove();
190
+ }
191
+ updateViewportScale() {
192
+ if (this.destroyed) {
193
+ return;
194
+ }
195
+ const matrix = this.svg.Node.getScreenCTM();
196
+ if (matrix) {
197
+ const scale = Math.hypot(matrix.a, matrix.b);
198
+ this.traces.forEach((trace) => trace.setViewportScale(scale));
199
+ }
200
+ this.labelLayout.update();
201
+ }
202
+ assertAlive() {
203
+ if (this.destroyed) {
204
+ throw new Error('This Smith chart has been destroyed. Create a new instance.');
205
+ }
206
+ }
207
+ createScalers(size) {
208
+ const impedance = new SmithScaler(d3.scaleLinear().domain([-1, 1]).range([0, size]), d3.scaleLinear().domain([1, -1]).range([0, size]), d3
209
+ .scaleLinear()
210
+ .domain([0, 1])
211
+ .range([0, size / 2]));
212
+ const admittance = new SmithScaler(d3.scaleLinear().domain([1, -1]).range([0, size]), d3.scaleLinear().domain([-1, 1]).range([0, size]), d3
213
+ .scaleLinear()
214
+ .domain([0, 1])
215
+ .range([0, size / 2]));
216
+ return { default: impedance, impedance, admittance };
217
+ }
218
+ cursorMove(p) {
219
+ if (this.markerDragging) {
220
+ return;
221
+ }
222
+ this.cursor.Position = Complex.from(this.scalers.default.pointInvert(p));
223
+ }
224
+ setMarkerDragging(dragging) {
225
+ this.markerDragging = dragging;
226
+ if (dragging) {
227
+ this.cursorBeforeMarkerDrag = this.svg.Node.style.getPropertyValue('cursor') || null;
228
+ this.svg.Element.style('cursor', 'grabbing').style('--smithkit-marker-cursor', 'grabbing');
229
+ this.cursor.hide();
230
+ }
231
+ else {
232
+ this.svg.Element.style('cursor', () => this.cursorBeforeMarkerDrag).style('--smithkit-marker-cursor', null);
233
+ this.cursorBeforeMarkerDrag = null;
234
+ }
235
+ }
236
+ hideCursor() {
237
+ this.cursor.hide();
238
+ }
239
+ initCursor() {
240
+ const cursor = new SmithCursor(this.scalers.default);
241
+ cursor.Group.attr('class', 'smith-cursor');
242
+ cursor.setMoveHandler(() => {
243
+ this.onCursorChange?.(this.cursor.Position);
244
+ });
245
+ return cursor;
246
+ }
247
+ get cursorPosition() {
248
+ return this.cursor.Position;
249
+ }
250
+ initializeZoom() {
251
+ const zoom = this.zoomBehavior
252
+ .scaleExtent([0.6, 1000])
253
+ .filter((event) => this.zoomEnabled &&
254
+ !this.markerDragging &&
255
+ (!event.ctrlKey || event.type === 'wheel') &&
256
+ !event.button)
257
+ .on('start', (event) => {
258
+ if (event.sourceEvent) {
259
+ this.mouseGesture.capture(event.sourceEvent, 'zoom');
260
+ }
261
+ })
262
+ .on('zoom', (event) => {
263
+ if ((!this.zoomEnabled || this.markerDragging) && !this.applyingView) {
264
+ // An already active mouse/touch gesture still needs its normal end event.
265
+ // Restore D3's view through its public API while that gesture finishes.
266
+ this.applyView(this.transform);
267
+ return;
268
+ }
269
+ this.onZoom(event.transform);
270
+ });
271
+ this.svg.Element.call(zoom);
272
+ this.resetView();
273
+ }
274
+ /** Enable or disable wheel, double-click, and mouse/touch zoom/pan. Keeps the current view. */
275
+ setZoomEnabled(enabled) {
276
+ this.assertAlive();
277
+ if (typeof enabled !== 'boolean') {
278
+ throw new TypeError('Zoom enabled must be a boolean.');
279
+ }
280
+ if (this.zoomEnabled === enabled) {
281
+ return;
282
+ }
283
+ this.zoomEnabled = enabled;
284
+ if (!enabled) {
285
+ this.svg.Element.interrupt();
286
+ }
287
+ }
288
+ applyView(transform) {
289
+ this.applyingView = true;
290
+ try {
291
+ this.svg.Element.call(this.zoomBehavior.transform, transform);
292
+ }
293
+ finally {
294
+ this.applyingView = false;
295
+ }
296
+ }
297
+ resetView() {
298
+ this.assertAlive();
299
+ const transform = this.defaultTransform;
300
+ this.applyView(transform);
301
+ }
302
+ onZoom(transform) {
303
+ if (this.destroyed) {
304
+ return;
305
+ }
306
+ this.transform = transform;
307
+ this.container.Element.attr('transform', transform.toString());
308
+ this.traces.forEach((d) => d.zoom(transform));
309
+ }
310
+ cursorContainer() {
311
+ const shape = this.drawReactanceAxis({ fill: 'transparent', stroke: 'none' });
312
+ shape.Element.style('pointer-events', 'all')
313
+ .on('pointermove.smithkit', (event) => {
314
+ this.cursorMove(d3.pointer(event));
315
+ })
316
+ .on('pointerleave.smithkit', () => {
317
+ this.cursor.hide();
318
+ this.onCursorChange?.(undefined);
319
+ });
320
+ return shape;
321
+ }
322
+ drawReactanceAxis(opts) {
323
+ const c = this.calcs.resistanceCircle(0);
324
+ c.p[0] = this.scalers.default.x(c.p[0]);
325
+ c.p[1] = this.scalers.default.y(c.p[1]);
326
+ c.r = this.scalers.default.r(c.r);
327
+ return new SmithCircle(c, opts);
328
+ }
329
+ /** Construct trace rendering without exposing chart containers or scalers to Smith. */
330
+ createTrace(values, color, style, onMarkerDrag) {
331
+ this.assertAlive();
332
+ const trace = new SmithData(values, color, this.transform, this.dataContainer, this.scalers.default, onMarkerDrag, this.markerContainer, style);
333
+ this.traces.add(trace);
334
+ this.updateViewportScale();
335
+ return trace;
336
+ }
337
+ removeTrace(trace) {
338
+ // Release gestures while Smith can still resolve the marker's public snapshot.
339
+ trace.destroy();
340
+ this.traces.delete(trace);
341
+ }
342
+ }
@@ -42,7 +42,7 @@ export class SmithPeripheralScales extends SmithGroup {
42
42
  .attr('cy', 250)
43
43
  .attr('r', (radius) => radius)
44
44
  .attr('fill', 'none')
45
- .attr('stroke', '#334155')
45
+ .attr('stroke', 'var(--smithkit-scales-boundaryColor)')
46
46
  .attr('stroke-width', 1.1)
47
47
  .attr('vector-effect', 'non-scaling-stroke');
48
48
  this.axes.each(function (scale, index) {
@@ -60,7 +60,7 @@ export class SmithPeripheralScales extends SmithGroup {
60
60
  .attr('cy', 250)
61
61
  .attr('r', radius)
62
62
  .attr('fill', 'none')
63
- .attr('stroke', '#475569')
63
+ .attr('stroke', 'var(--smithkit-scales-stroke)')
64
64
  .attr('stroke-width', 0.6)
65
65
  .attr('vector-effect', 'non-scaling-stroke');
66
66
  }
@@ -75,7 +75,7 @@ export class SmithPeripheralScales extends SmithGroup {
75
75
  .attr('y1', y1)
76
76
  .attr('x2', x2)
77
77
  .attr('y2', y2)
78
- .attr('stroke', '#475569')
78
+ .attr('stroke', 'var(--smithkit-scales-stroke)')
79
79
  .attr('stroke-width', major ? 0.8 : 0.5)
80
80
  .attr('vector-effect', 'non-scaling-stroke');
81
81
  const captionDistance = Math.abs(((angle - captionAngle + 540) % 360) - 180);
@@ -89,9 +89,9 @@ export class SmithPeripheralScales extends SmithGroup {
89
89
  .attr('transform', `rotate(${rotation},${x},${y})`)
90
90
  .attr('text-anchor', 'middle')
91
91
  .attr('dominant-baseline', 'central')
92
- .attr('font-family', 'system-ui, sans-serif')
93
- .attr('font-size', 10.5)
94
- .attr('fill', '#334155')
92
+ .attr('font-family', 'var(--smithkit-fontFamily)')
93
+ .attr('font-size', 'var(--smithkit-scales-peripheralFontSize)')
94
+ .attr('fill', 'var(--smithkit-scales-textColor)')
95
95
  .text(scale.unit === 'λ' ? value.toFixed(2) : String(value));
96
96
  }
97
97
  }
@@ -114,11 +114,11 @@ export class SmithPeripheralScales extends SmithGroup {
114
114
  .append('text')
115
115
  .attr('class', 'peripheral-caption')
116
116
  .attr('text-anchor', 'middle')
117
- .attr('font-family', 'system-ui, sans-serif')
118
- .attr('font-size', 10.5)
117
+ .attr('font-family', 'var(--smithkit-fontFamily)')
118
+ .attr('font-size', 'var(--smithkit-scales-peripheralFontSize)')
119
119
  .attr('font-weight', 500)
120
120
  .attr('letter-spacing', 0.3)
121
- .attr('fill', '#334155')
121
+ .attr('fill', 'var(--smithkit-scales-textColor)')
122
122
  .append('textPath')
123
123
  .attr('href', `#${pathId}`)
124
124
  .attr('startOffset', '50%')
@@ -128,8 +128,8 @@ export class SmithPeripheralScales extends SmithGroup {
128
128
  .attr('class', 'peripheral-indicator')
129
129
  .attr('r', 3)
130
130
  .attr('visibility', 'hidden')
131
- .attr('fill', '#dc2626')
132
- .attr('stroke', 'white')
131
+ .attr('fill', 'var(--smithkit-scales-indicatorColor)')
132
+ .attr('stroke', 'var(--smithkit-scales-indicatorOutline)')
133
133
  .attr('stroke-width', 1);
134
134
  });
135
135
  }
@@ -1,3 +1,5 @@
1
+ import type { ScaleImageExportOptions } from '../svg/export.js';
2
+ import type { SmithAppearance } from '../appearance/types.js';
1
3
  import type { Complex } from '../math/Complex.js';
2
4
  /** Independent parameter scales. Connect update() to a chart's cursor events. */
3
5
  export declare class SmithScales {
@@ -7,12 +9,17 @@ export declare class SmithScales {
7
9
  private readonly start;
8
10
  private readonly length;
9
11
  private readonly axes;
10
- constructor();
12
+ constructor(appearance?: SmithAppearance);
13
+ /** Replace the preset and overrides without changing readings or layout. */
14
+ setAppearance(appearance: SmithAppearance): void;
11
15
  draw(target: string | HTMLElement): void;
12
16
  /** Show a passive-load reflection coefficient, or hide indicators with null. */
13
17
  update(gamma: Complex | null): void;
14
- /** Export all mounted scales in their current layout as a standalone SVG. */
15
- toSvg(): string;
18
+ private updateAxis;
19
+ /** Export mounted scales, optionally with a separate reading and its description. */
20
+ toSvg(options?: ScaleImageExportOptions): string;
21
+ /** Export mounted scales as PNG without changing their live readings. */
22
+ toPng(options?: ScaleImageExportOptions): Promise<Blob>;
16
23
  destroy(): void;
17
24
  private assertAlive;
18
25
  }
@@ -1,3 +1,6 @@
1
+ import { ImageExporter } from '../svg/ImageExporter.js';
2
+ import { Theme } from '../appearance/Theme.js';
3
+ import { SvgTheme } from '../appearance/SvgTheme.js';
1
4
  import { select, format } from 'd3';
2
5
  import { RadialScaleDefinitions } from './RadialScaleDefinitions.js';
3
6
  import { SvgExporter } from '../svg/SvgExporter.js';
@@ -24,25 +27,27 @@ export class SmithScales {
24
27
  .style('display', 'block')
25
28
  .style('width', '100%')
26
29
  .style('height', 'auto');
27
- constructor() {
30
+ constructor(appearance = {}) {
31
+ this.setAppearance(appearance);
28
32
  const labels = this.axes.append('g').attr('data-role', 'labels').attr('pointer-events', 'none');
29
33
  labels
30
34
  .append('text')
31
35
  .attr('x', this.start)
32
36
  .attr('y', 12)
33
- .attr('font-family', 'system-ui, sans-serif')
34
- .attr('font-size', 12)
35
- .attr('fill', '#334155')
37
+ .attr('font-family', 'var(--smithkit-fontFamily)')
38
+ .attr('font-size', 'var(--smithkit-scales-titleFontSize)')
39
+ .attr('fill', 'var(--smithkit-scales-textColor)')
36
40
  .text((scale) => scale.title);
37
41
  labels
38
42
  .append('text')
39
43
  .attr('class', 'scale-value')
44
+ .attr('data-role', 'scale-value')
40
45
  .attr('x', this.start)
41
46
  .attr('y', 25)
42
- .attr('font-family', 'system-ui, sans-serif')
43
- .attr('font-size', 11)
47
+ .attr('font-family', 'var(--smithkit-fontFamily)')
48
+ .attr('font-size', 'var(--smithkit-scales-valueFontSize)')
44
49
  .attr('font-weight', 600)
45
- .attr('fill', '#b42318')
50
+ .attr('fill', 'var(--smithkit-scales-valueColor)')
46
51
  .text('—');
47
52
  this.axes
48
53
  .append('line')
@@ -51,7 +56,7 @@ export class SmithScales {
51
56
  .attr('x2', this.start + this.length)
52
57
  .attr('y1', 36)
53
58
  .attr('y2', 36)
54
- .attr('stroke', '#64748b')
59
+ .attr('stroke', 'var(--smithkit-scales-stroke)')
55
60
  .attr('stroke-width', 1);
56
61
  const start = this.start;
57
62
  const length = this.length;
@@ -62,7 +67,11 @@ export class SmithScales {
62
67
  .join('g')
63
68
  .attr('class', 'scale-tick')
64
69
  .attr('transform', (value) => `translate(${start + length * scale.position(value)},36)`);
65
- ticks.append('line').attr('y2', 5).attr('stroke', '#64748b').attr('stroke-width', 1);
70
+ ticks
71
+ .append('line')
72
+ .attr('y2', 5)
73
+ .attr('stroke', 'var(--smithkit-scales-stroke)')
74
+ .attr('stroke-width', 1);
66
75
  select(this)
67
76
  .select('[data-role=labels]')
68
77
  .selectAll('g')
@@ -71,9 +80,9 @@ export class SmithScales {
71
80
  .attr('transform', (value) => `translate(${start + length * scale.position(value)},36)`)
72
81
  .append('text')
73
82
  .attr('y', 17)
74
- .attr('font-size', 10)
75
- .attr('font-family', 'system-ui, sans-serif')
76
- .attr('fill', '#334155')
83
+ .attr('font-size', 'var(--smithkit-scales-fontSize)')
84
+ .attr('font-family', 'var(--smithkit-fontFamily)')
85
+ .attr('fill', 'var(--smithkit-scales-textColor)')
77
86
  .attr('text-anchor', (_, i) => i === 0 ? 'start' : i === scale.values.length - 1 ? 'end' : 'middle')
78
87
  .text((value) => (value === Infinity ? '∞' : String(value)));
79
88
  });
@@ -84,12 +93,17 @@ export class SmithScales {
84
93
  .attr('cx', this.start)
85
94
  .attr('cy', 36)
86
95
  .attr('r', 4)
87
- .attr('fill', '#dc2626')
88
- .attr('stroke', 'white')
96
+ .attr('fill', 'var(--smithkit-scales-indicatorColor)')
97
+ .attr('stroke', 'var(--smithkit-scales-indicatorOutline)')
89
98
  .attr('stroke-width', 1);
90
99
  labels.raise();
91
100
  this.axes.append('title').text((scale) => scale.description ?? scale.title);
92
101
  }
102
+ /** Replace the preset and overrides without changing readings or layout. */
103
+ setAppearance(appearance) {
104
+ this.assertAlive();
105
+ SvgTheme.apply(this.container.node(), Theme.resolve(appearance));
106
+ }
93
107
  draw(target) {
94
108
  this.assertAlive();
95
109
  const host = typeof target === 'string' ? document.querySelector(target) : target;
@@ -101,37 +115,57 @@ export class SmithScales {
101
115
  /** Show a passive-load reflection coefficient, or hide indicators with null. */
102
116
  update(gamma) {
103
117
  this.assertAlive();
118
+ this.axes.each((scale, index, nodes) => this.updateAxis(nodes[index], scale, gamma));
119
+ }
120
+ updateAxis(node, scale, gamma) {
121
+ const axis = select(node);
104
122
  const magnitude = gamma === null ? NaN : Math.hypot(gamma.re, gamma.im);
105
123
  if (!gamma || !Number.isFinite(magnitude) || magnitude > 1) {
106
- this.axes.select('circle').attr('visibility', 'hidden').attr('data-position', null);
107
- this.axes.select('.scale-value').text('—');
108
- this.axes.attr('aria-label', (scale) => `${scale.title}. ${scale.description ?? ''}`.trim());
109
- this.axes.select('title').text((scale) => scale.description ?? scale.title);
110
- return;
111
- }
112
- const start = this.start;
113
- const length = this.length;
114
- this.axes.each(function (scale) {
115
- const value = scale.read(gamma);
116
- const position = Math.max(0, Math.min(1, scale.position(value)));
117
- const axis = select(this);
118
- const number = value === Infinity ? '∞' : format('.4~g')(value);
119
- const suffix = scale.unit === 'dB' ? ' dB' : scale.id === 'vswr' ? ' : 1' : '';
120
- const readout = number + suffix;
121
- const label = `${scale.title}: ${readout}${scale.description ? `. ${scale.description}` : ''}`;
122
- axis.select('.scale-value').text(readout);
123
- axis.attr('aria-label', label).select('title').text(label);
124
124
  axis
125
125
  .select('circle')
126
- .attr('cx', start + position * length)
127
- .attr('data-position', position)
128
- .attr('visibility', null);
129
- });
126
+ .attr('visibility', 'hidden')
127
+ .style('visibility', 'hidden')
128
+ .attr('data-position', null);
129
+ axis.select('[data-role="scale-value"]').text('—');
130
+ axis.attr('aria-label', `${scale.title}. ${scale.description ?? ''}`.trim());
131
+ axis.select('title').text(scale.description ?? scale.title);
132
+ return;
133
+ }
134
+ const value = scale.read(gamma);
135
+ const position = Math.max(0, Math.min(1, scale.position(value)));
136
+ const number = value === Infinity ? '∞' : format('.4~g')(value);
137
+ const suffix = scale.unit === 'dB' ? ' dB' : scale.id === 'vswr' ? ' : 1' : '';
138
+ const readout = number + suffix;
139
+ const label = `${scale.title}: ${readout}${scale.description ? `. ${scale.description}` : ''}`;
140
+ axis.select('[data-role="scale-value"]').text(readout);
141
+ axis.attr('aria-label', label).select('title').text(label);
142
+ axis
143
+ .select('circle')
144
+ .attr('cx', this.start + position * this.length)
145
+ .attr('data-position', position)
146
+ .attr('visibility', null)
147
+ .style('visibility', 'visible');
130
148
  }
131
- /** Export all mounted scales in their current layout as a standalone SVG. */
132
- toSvg() {
149
+ /** Export mounted scales, optionally with a separate reading and its description. */
150
+ toSvg(options) {
133
151
  this.assertAlive();
134
- return SvgExporter.scales(this.container.node(), this.axes.nodes());
152
+ const container = this.container.node();
153
+ const style = getComputedStyle(container);
154
+ const readout = options?.readout;
155
+ const source = SvgExporter.scales(container, this.axes.nodes(), readout
156
+ ? (copy, index) => this.updateAxis(copy, this.scales[index], readout.reflectionCoefficient)
157
+ : undefined, readout?.label
158
+ ? {
159
+ text: readout.label,
160
+ color: style.getPropertyValue('--smithkit-scales-textColor').trim(),
161
+ fontFamily: style.getPropertyValue('--smithkit-fontFamily').trim(),
162
+ }
163
+ : undefined);
164
+ return options === undefined ? source : ImageExporter.svg([source], options);
165
+ }
166
+ /** Export mounted scales as PNG without changing their live readings. */
167
+ async toPng(options = {}) {
168
+ return ImageExporter.png([this.toSvg({ readout: options.readout })], options);
135
169
  }
136
170
  destroy() {
137
171
  if (this.destroyed) {
@@ -0,0 +1,18 @@
1
+ import type { ImageExportOptions } from './export.js';
2
+ interface Legend {
3
+ entries: {
4
+ name: string;
5
+ color: string;
6
+ }[];
7
+ textColor: string;
8
+ fontFamily: string;
9
+ }
10
+ /** Compose SVG snapshots and rasterize PNG reports without changing live chart nodes. */
11
+ export declare class ImageExporter {
12
+ private constructor();
13
+ private static layout;
14
+ static png(sources: string[], options: ImageExportOptions, legend?: Legend): Promise<Blob>;
15
+ static svg(sources: string[], options: ImageExportOptions, legend?: Legend): string;
16
+ private static wrap;
17
+ }
18
+ export {};