smithkit 0.2.0 → 0.4.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 (48) hide show
  1. package/README.md +318 -25
  2. package/dist/lib/OptionsValidation.d.ts +16 -0
  3. package/dist/lib/OptionsValidation.js +168 -0
  4. package/dist/lib/Smith.d.ts +24 -39
  5. package/dist/lib/Smith.js +175 -287
  6. package/dist/lib/appearance/SvgTheme.d.ts +6 -0
  7. package/dist/lib/appearance/SvgTheme.js +21 -0
  8. package/dist/lib/appearance/Theme.d.ts +8 -0
  9. package/dist/lib/appearance/Theme.js +125 -0
  10. package/dist/lib/appearance/types.d.ts +55 -0
  11. package/dist/lib/appearance/types.js +1 -0
  12. package/dist/lib/grid/CircleLayerControl.d.ts +1 -1
  13. package/dist/lib/grid/CircleLayerControl.js +10 -8
  14. package/dist/lib/grid/ConstCircles.d.ts +0 -3
  15. package/dist/lib/grid/ConstCircles.js +6 -20
  16. package/dist/lib/grid/ConstQCircles.d.ts +1 -0
  17. package/dist/lib/grid/ConstQCircles.js +6 -1
  18. package/dist/lib/grid/ConstSwrCircles.d.ts +1 -0
  19. package/dist/lib/grid/ConstSwrCircles.js +6 -1
  20. package/dist/lib/grid/GridDefinitions.d.ts +7 -1
  21. package/dist/lib/grid/GridDefinitions.js +29 -0
  22. package/dist/lib/grid/GridLayerControl.d.ts +5 -4
  23. package/dist/lib/grid/GridLayerControl.js +13 -3
  24. package/dist/lib/grid/LayerControl.d.ts +1 -2
  25. package/dist/lib/grid/LayerControl.js +1 -10
  26. package/dist/lib/grid/SmithGridLayer.d.ts +7 -1
  27. package/dist/lib/grid/SmithGridLayer.js +35 -5
  28. package/dist/lib/index.d.ts +4 -1
  29. package/dist/lib/interaction/SmithCursor.d.ts +0 -17
  30. package/dist/lib/interaction/SmithCursor.js +6 -14
  31. package/dist/lib/layers.d.ts +7 -1
  32. package/dist/lib/options.d.ts +54 -0
  33. package/dist/lib/options.js +1 -0
  34. package/dist/lib/rendering/SvgChartRenderer.d.ts +69 -0
  35. package/dist/lib/rendering/SvgChartRenderer.js +364 -0
  36. package/dist/lib/scales/SmithPeripheralScales.js +11 -11
  37. package/dist/lib/scales/SmithScales.d.ts +10 -3
  38. package/dist/lib/scales/SmithScales.js +73 -39
  39. package/dist/lib/svg/ImageExporter.d.ts +18 -0
  40. package/dist/lib/svg/ImageExporter.js +211 -0
  41. package/dist/lib/svg/MarkerLegend.d.ts +12 -0
  42. package/dist/lib/svg/MarkerLegend.js +63 -0
  43. package/dist/lib/svg/SvgExporter.d.ts +6 -1
  44. package/dist/lib/svg/SvgExporter.js +50 -5
  45. package/dist/lib/svg/export.d.ts +36 -0
  46. package/dist/lib/svg/export.js +1 -0
  47. package/dist/lib/traces/SmithMarker.js +8 -5
  48. package/package.json +1 -1
@@ -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 {};
@@ -0,0 +1,211 @@
1
+ /** Compose SVG snapshots and rasterize PNG reports without changing live chart nodes. */
2
+ export class ImageExporter {
3
+ constructor() { }
4
+ static layout(sources, options, legend) {
5
+ const { width: requestedWidth, height: requestedHeight, background } = options;
6
+ for (const value of [requestedWidth, requestedHeight]) {
7
+ if (value !== undefined && (!Number.isInteger(value) || value < 1 || value > 8192)) {
8
+ throw new RangeError('Export dimensions must be integers from 1 to 8192 pixels.');
9
+ }
10
+ }
11
+ if (background !== undefined &&
12
+ (!CSS.supports('color', background) ||
13
+ /\b(var|currentcolor|inherit|initial|unset|revert|light-dark)\b/i.test(background))) {
14
+ throw new RangeError('Export background must be a concrete CSS color or transparent.');
15
+ }
16
+ const snapshots = sources.map((source) => {
17
+ const svg = new DOMParser().parseFromString(source, 'image/svg+xml').documentElement;
18
+ if (background !== undefined) {
19
+ svg.querySelectorAll('[data-export-background]').forEach((node) => node.remove());
20
+ }
21
+ return {
22
+ svg,
23
+ width: Number(svg.getAttribute('width')),
24
+ height: Number(svg.getAttribute('height')),
25
+ };
26
+ });
27
+ const gap = snapshots.length > 1 ? 24 : 0;
28
+ const contentWidth = Math.max(...snapshots.map((source) => source.width));
29
+ const contentHeight = snapshots.reduce((sum, source) => sum + source.height, 0) + gap * (snapshots.length - 1);
30
+ const canvas = document.createElement('canvas');
31
+ const context = canvas.getContext('2d');
32
+ if (!context) {
33
+ throw new Error('Image export requires a Canvas 2D context.');
34
+ }
35
+ context.font = `14px ${legend?.fontFamily ?? 'sans-serif'}`;
36
+ const rows = (legend?.entries ?? []).flatMap((entry) => {
37
+ const lines = ImageExporter.wrap(entry.name, Math.max(1, contentWidth - 64), context);
38
+ return lines.map((text, index) => ({ text, color: entry.color, swatch: index === 0 }));
39
+ });
40
+ const legendHeight = rows.length ? 32 + rows.length * 22 : 0;
41
+ const naturalHeight = contentHeight + legendHeight;
42
+ const width = requestedWidth ??
43
+ Math.max(1, Math.round(requestedHeight === undefined
44
+ ? contentWidth
45
+ : (requestedHeight * contentWidth) / naturalHeight));
46
+ const height = requestedHeight ?? Math.max(1, Math.round((width * naturalHeight) / contentWidth));
47
+ if (width > 8192 || height > 8192 || width * height > 33_554_432) {
48
+ throw new RangeError('Image output must not exceed 8192 pixels per side or 32 megapixels.');
49
+ }
50
+ const scale = Math.min(width / contentWidth, height / naturalHeight);
51
+ return {
52
+ snapshots,
53
+ gap,
54
+ contentWidth,
55
+ contentHeight,
56
+ context,
57
+ canvas,
58
+ rows,
59
+ legendHeight,
60
+ width,
61
+ height,
62
+ scale,
63
+ naturalHeight,
64
+ };
65
+ }
66
+ static async png(sources, options, legend) {
67
+ const snapshotOptions = { ...options };
68
+ await document.fonts.ready;
69
+ const { snapshots, gap, contentWidth, contentHeight, context, canvas, rows, legendHeight, width, height, scale, naturalHeight, } = ImageExporter.layout(sources, snapshotOptions, legend);
70
+ const background = snapshotOptions.background;
71
+ canvas.width = width;
72
+ canvas.height = height;
73
+ // Explicit backgrounds replace component backgrounds; omission preserves them.
74
+ if (background !== undefined) {
75
+ context.fillStyle = background;
76
+ context.fillRect(0, 0, width, height);
77
+ }
78
+ context.translate((width - contentWidth * scale) / 2, (height - naturalHeight * scale) / 2);
79
+ context.scale(scale, scale);
80
+ let y = 0;
81
+ for (const source of snapshots) {
82
+ const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(source.svg)], {
83
+ type: 'image/svg+xml;charset=utf-8',
84
+ }));
85
+ try {
86
+ const image = new Image();
87
+ image.src = url;
88
+ await image.decode();
89
+ context.drawImage(image, (contentWidth - source.width) / 2, y, source.width, source.height);
90
+ }
91
+ finally {
92
+ URL.revokeObjectURL(url);
93
+ }
94
+ y += source.height + gap;
95
+ }
96
+ if (legend && rows.length) {
97
+ // Use the chart's own background for the footer unless explicitly overridden.
98
+ const chartBackground = snapshots[0].svg
99
+ .querySelector('[data-export-background]')
100
+ ?.getAttribute('fill');
101
+ if (background === undefined && chartBackground) {
102
+ context.fillStyle = chartBackground;
103
+ context.fillRect(0, contentHeight, contentWidth, legendHeight);
104
+ }
105
+ context.font = `14px ${legend.fontFamily}`;
106
+ context.textBaseline = 'middle';
107
+ for (const [index, row] of rows.entries()) {
108
+ const y = contentHeight + 24 + index * 22;
109
+ if (row.swatch) {
110
+ context.strokeStyle = row.color;
111
+ context.lineWidth = 3;
112
+ context.beginPath();
113
+ context.moveTo(16, y);
114
+ context.lineTo(40, y);
115
+ context.stroke();
116
+ }
117
+ context.fillStyle = legend.textColor;
118
+ context.fillText(row.text, 48, y);
119
+ }
120
+ }
121
+ return new Promise((resolve, reject) => {
122
+ canvas.toBlob((blob) => {
123
+ if (blob) {
124
+ resolve(blob);
125
+ }
126
+ else {
127
+ reject(new Error('The browser could not encode the PNG image.'));
128
+ }
129
+ }, 'image/png');
130
+ });
131
+ }
132
+ static svg(sources, options, legend) {
133
+ const { snapshots, gap, contentWidth, contentHeight, rows, legendHeight, width, height, scale, naturalHeight, } = ImageExporter.layout(sources, options, legend);
134
+ const namespace = 'http://www.w3.org/2000/svg';
135
+ const root = document.createElementNS(namespace, 'svg');
136
+ const left = -(width / scale - contentWidth) / 2;
137
+ const top = -(height / scale - naturalHeight) / 2;
138
+ root.setAttribute('width', String(width));
139
+ root.setAttribute('height', String(height));
140
+ root.setAttribute('viewBox', `${left} ${top} ${width / scale} ${height / scale}`);
141
+ const append = (tag, attributes) => {
142
+ const node = document.createElementNS(namespace, tag);
143
+ for (const [name, value] of Object.entries(attributes)) {
144
+ node.setAttribute(name, String(value));
145
+ }
146
+ root.appendChild(node);
147
+ return node;
148
+ };
149
+ if (options.background !== undefined) {
150
+ append('rect', {
151
+ x: left,
152
+ y: top,
153
+ width: width / scale,
154
+ height: height / scale,
155
+ fill: options.background,
156
+ });
157
+ }
158
+ let y = 0;
159
+ for (const source of snapshots) {
160
+ source.svg.setAttribute('x', String((contentWidth - source.width) / 2));
161
+ source.svg.setAttribute('y', String(y));
162
+ root.appendChild(source.svg);
163
+ y += source.height + gap;
164
+ }
165
+ if (legend && rows.length) {
166
+ const chartBackground = snapshots[0].svg
167
+ .querySelector('[data-export-background]')
168
+ ?.getAttribute('fill');
169
+ if (options.background === undefined && chartBackground) {
170
+ append('rect', {
171
+ x: 0,
172
+ y: contentHeight,
173
+ width: contentWidth,
174
+ height: legendHeight,
175
+ fill: chartBackground,
176
+ });
177
+ }
178
+ for (const [index, row] of rows.entries()) {
179
+ const y = contentHeight + 24 + index * 22;
180
+ if (row.swatch) {
181
+ append('line', { x1: 16, x2: 40, y1: y, y2: y, stroke: row.color, 'stroke-width': 3 });
182
+ }
183
+ const text = append('text', {
184
+ x: 48,
185
+ y,
186
+ fill: legend.textColor,
187
+ 'font-size': 14,
188
+ 'font-family': legend.fontFamily,
189
+ 'dominant-baseline': 'central',
190
+ });
191
+ text.textContent = row.text;
192
+ }
193
+ }
194
+ return new XMLSerializer().serializeToString(root);
195
+ }
196
+ static wrap(text, width, context) {
197
+ const lines = [];
198
+ let line = '';
199
+ for (const character of text) {
200
+ if (character === '\n' || (line && context.measureText(line + character).width > width)) {
201
+ lines.push(line);
202
+ line = '';
203
+ }
204
+ if (character !== '\n') {
205
+ line += character;
206
+ }
207
+ }
208
+ lines.push(line);
209
+ return lines;
210
+ }
211
+ }
@@ -0,0 +1,12 @@
1
+ import type { MarkerSnapshot, TraceInfo } from '../measurements.js';
2
+ import type { MarkerLegendOptions } from './export.js';
3
+ /** Format detached marker readings for the shared SVG/PNG report footer. */
4
+ export declare class MarkerLegend {
5
+ private constructor();
6
+ static entries(traces: readonly TraceInfo[], reading: (id: string) => MarkerSnapshot, options: true | MarkerLegendOptions): {
7
+ name: string;
8
+ color: string;
9
+ }[];
10
+ private static field;
11
+ private static number;
12
+ }
@@ -0,0 +1,63 @@
1
+ import { SmithFormatter } from '../SmithFormatter.js';
2
+ /** Format detached marker readings for the shared SVG/PNG report footer. */
3
+ export class MarkerLegend {
4
+ constructor() { }
5
+ static entries(traces, reading, options) {
6
+ const { markerIds, fields = ['frequency', 'impedance'] } = options === true ? {} : options;
7
+ const knownIds = new Set(traces.flatMap((trace) => trace.markers.map((marker) => marker.id)));
8
+ if (markerIds?.some((id) => !knownIds.has(id))) {
9
+ throw new RangeError('Marker legend contains an unknown marker ID.');
10
+ }
11
+ const available = [
12
+ 'frequency',
13
+ 'impedance',
14
+ 'admittance',
15
+ 'reflectionCoefficient',
16
+ 'vswr',
17
+ 'returnLoss',
18
+ ];
19
+ if (fields.some((field) => !available.includes(field))) {
20
+ throw new RangeError('Marker legend contains an unknown field.');
21
+ }
22
+ const selected = markerIds === undefined ? undefined : new Set(markerIds);
23
+ return traces
24
+ .filter((trace) => trace.visible)
25
+ .flatMap((trace) => trace.markers
26
+ .filter((marker) => selected === undefined || selected.has(marker.id))
27
+ .map((marker) => {
28
+ const snapshot = reading(marker.id);
29
+ return {
30
+ color: trace.color,
31
+ name: [
32
+ `${trace.name} · Marker ${marker.number}`,
33
+ ...[...new Set(fields)].map((field) => MarkerLegend.field(snapshot, field)),
34
+ ].join(' · '),
35
+ };
36
+ }));
37
+ }
38
+ static field(marker, field) {
39
+ switch (field) {
40
+ case 'frequency':
41
+ return `f = ${SmithFormatter.number(marker.frequencyHz)}Hz`;
42
+ case 'impedance':
43
+ return `Z = ${marker.impedanceOhms?.toString(3) ?? '∞'} Ω`;
44
+ case 'admittance':
45
+ return `Y = ${marker.admittanceSiemens?.mul(1000).toString(3) ?? '∞'} mS`;
46
+ case 'reflectionCoefficient':
47
+ return `Γ = ${marker.reflectionCoefficient.toString(3)}`;
48
+ case 'vswr':
49
+ return `VSWR = ${MarkerLegend.number(marker.vswr)}${marker.vswr === undefined ? '' : ' : 1'}`;
50
+ case 'returnLoss':
51
+ return `Return loss = ${MarkerLegend.number(marker.returnLossDb)} dB`;
52
+ }
53
+ }
54
+ static number(value) {
55
+ if (value === undefined || Number.isNaN(value)) {
56
+ return '—';
57
+ }
58
+ if (!Number.isFinite(value)) {
59
+ return value < 0 ? '−∞' : '∞';
60
+ }
61
+ return String(Number(value.toPrecision(4)));
62
+ }
63
+ }
@@ -6,7 +6,12 @@ export declare class SvgExporter {
6
6
  private static readonly nonInherited;
7
7
  static chart(source: SVGElement): string;
8
8
  /** Preserve the current CSS layout of separate scale SVGs in one SVG document. */
9
- static scales(container: HTMLElement, axes: readonly SVGSVGElement[]): string;
9
+ static scales(container: HTMLElement, axes: readonly SVGSVGElement[], customize?: (copy: SVGElement, index: number) => void, caption?: {
10
+ text: string;
11
+ color: string;
12
+ fontFamily: string;
13
+ }): string;
14
+ private static background;
10
15
  private static bounds;
11
16
  private static copy;
12
17
  private static size;
@@ -49,25 +49,70 @@ export class SvgExporter {
49
49
  static chart(source) {
50
50
  const bounds = SvgExporter.bounds(source);
51
51
  const copy = SvgExporter.copy(source);
52
+ SvgExporter.background(copy, source);
52
53
  SvgExporter.size(copy, bounds.width, bounds.height);
53
54
  return SvgExporter.serialize(copy);
54
55
  }
55
56
  /** Preserve the current CSS layout of separate scale SVGs in one SVG document. */
56
- static scales(container, axes) {
57
+ static scales(container, axes, customize, caption) {
57
58
  const bounds = SvgExporter.bounds(container);
58
59
  const root = container.ownerDocument.createElementNS(SvgExporter.namespace, 'svg');
59
- root.setAttribute('viewBox', `0 0 ${bounds.width} ${bounds.height}`);
60
- SvgExporter.size(root, bounds.width, bounds.height);
61
- for (const axis of axes) {
60
+ const lines = [];
61
+ if (caption?.text) {
62
+ const context = document.createElement('canvas').getContext('2d');
63
+ context.font = `14px ${caption.fontFamily}`;
64
+ let line = '';
65
+ for (const character of caption.text) {
66
+ if (character === '\n' ||
67
+ (line && context.measureText(line + character).width > Math.max(1, bounds.width - 32))) {
68
+ lines.push(line);
69
+ line = '';
70
+ }
71
+ if (character !== '\n') {
72
+ line += character;
73
+ }
74
+ }
75
+ lines.push(line);
76
+ }
77
+ const captionHeight = lines.length ? 16 + lines.length * 22 : 0;
78
+ root.setAttribute('viewBox', `0 0 ${bounds.width} ${bounds.height + captionHeight}`);
79
+ SvgExporter.size(root, bounds.width, bounds.height + captionHeight);
80
+ SvgExporter.background(root, container);
81
+ lines.forEach((line, index) => {
82
+ const text = container.ownerDocument.createElementNS(SvgExporter.namespace, 'text');
83
+ text.setAttribute('data-role', 'scale-readout-label');
84
+ text.setAttribute('x', '16');
85
+ text.setAttribute('y', String(22 + index * 22));
86
+ text.setAttribute('fill', caption.color);
87
+ text.setAttribute('font-family', caption.fontFamily);
88
+ text.setAttribute('font-size', '14');
89
+ text.textContent = line;
90
+ root.appendChild(text);
91
+ });
92
+ for (const [index, axis] of axes.entries()) {
62
93
  const position = SvgExporter.bounds(axis);
63
94
  const copy = SvgExporter.copy(axis);
64
95
  copy.setAttribute('x', String(position.x - bounds.x));
65
- copy.setAttribute('y', String(position.y - bounds.y));
96
+ copy.setAttribute('y', String(position.y - bounds.y + captionHeight));
66
97
  SvgExporter.size(copy, position.width, position.height);
98
+ customize?.(copy, index);
67
99
  root.appendChild(copy);
68
100
  }
69
101
  return SvgExporter.serialize(root);
70
102
  }
103
+ static background(root, source) {
104
+ const color = source.ownerDocument.defaultView.getComputedStyle(source).backgroundColor;
105
+ if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
106
+ return;
107
+ }
108
+ const rect = source.ownerDocument.createElementNS(SvgExporter.namespace, 'rect');
109
+ rect.setAttribute('data-export-background', 'true');
110
+ rect.setAttribute('width', '100%');
111
+ rect.setAttribute('height', '100%');
112
+ rect.setAttribute('fill', color);
113
+ rect.setAttribute('stroke', 'none');
114
+ root.insertBefore(rect, root.firstChild);
115
+ }
71
116
  static bounds(element) {
72
117
  const bounds = element.getBoundingClientRect();
73
118
  if (!element.isConnected || bounds.width <= 0 || bounds.height <= 0) {
@@ -0,0 +1,36 @@
1
+ import type { Complex } from '../math/Complex.js';
2
+ import type { SmithScales } from '../scales/SmithScales.js';
3
+ export interface ImageExportOptions {
4
+ /** Output pixels. Default: the snapshot dimensions. With only one dimension, preserve the complete image's aspect ratio. */
5
+ width?: number;
6
+ /** With both dimensions, center and fit the image without stretching or cropping. */
7
+ height?: number;
8
+ /** A concrete CSS color, or transparent. Omit to preserve configured SVG backgrounds. */
9
+ background?: string;
10
+ }
11
+ export interface ScaleExportReadout {
12
+ /** Use this reading instead of the live cursor/marker state; null exports empty scales. */
13
+ reflectionCoefficient: Complex | null;
14
+ /** Visible description of the reading, such as trace, marker, and frequency. */
15
+ label?: string;
16
+ }
17
+ export interface ScaleImageExportOptions extends ImageExportOptions {
18
+ readout?: ScaleExportReadout;
19
+ }
20
+ export type MarkerLegendField = 'frequency' | 'impedance' | 'admittance' | 'reflectionCoefficient' | 'vswr' | 'returnLoss';
21
+ export interface MarkerLegendOptions {
22
+ /** Default: all markers of visible traces, in trace/marker order. Empty selects none. Unknown IDs throw. */
23
+ markerIds?: readonly string[];
24
+ /** Default: frequency and impedance. Empty keeps only the trace name and marker number. */
25
+ fields?: readonly MarkerLegendField[];
26
+ }
27
+ export interface SmithImageExportOptions extends ImageExportOptions {
28
+ /** Include a footer with the names and colors of visible traces. Default: false. */
29
+ legend?: boolean;
30
+ /** Include a marker legend below the chart/scales, independently of the trace legend. Default: false. */
31
+ markerLegend?: boolean | MarkerLegendOptions;
32
+ /** Include mounted radial scales centered below the chart, preserving their current layout/readings. */
33
+ scales?: SmithScales;
34
+ /** Override readings in the included radial scales without changing the live view. Requires scales. */
35
+ scaleReadout?: ScaleExportReadout;
36
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -53,8 +53,8 @@ export class SmithMarker extends SmithShape {
53
53
  .attr('pointer-events', 'none')
54
54
  .attr('aria-hidden', 'true');
55
55
  for (const [stroke, width] of [
56
- ['white', 5],
57
- ['#1d4ed8', 2.5],
56
+ ['var(--smithkit-marker-focusHaloColor)', 5],
57
+ ['var(--smithkit-marker-focusColor)', 2.5],
58
58
  ]) {
59
59
  this.focusRing
60
60
  .append('circle')
@@ -63,17 +63,20 @@ export class SmithMarker extends SmithShape {
63
63
  .attr('stroke-width', width)
64
64
  .attr('vector-effect', 'non-scaling-stroke');
65
65
  }
66
- this.triangle = g.append('polygon').attr('stroke', 'none').attr('fill', 'gray');
66
+ this.triangle = g
67
+ .append('polygon')
68
+ .attr('stroke', 'none')
69
+ .attr('fill', 'var(--smithkit-marker-outlineColor)');
67
70
  this.inner = g.append('polygon').attr('stroke', 'none').attr('fill', color);
68
71
  this.text = g
69
72
  .append('text')
70
73
  .attr('pointer-events', 'none')
71
74
  .attr('aria-hidden', 'true')
72
- .attr('font-family', 'Verdana')
75
+ .attr('font-family', 'var(--smithkit-fontFamily)')
73
76
  .attr('font-weight', 'normal')
74
77
  .attr('text-anchor', 'middle')
75
78
  .attr('dominant-baseline', 'middle')
76
- .attr('fill', 'white')
79
+ .attr('fill', 'var(--smithkit-marker-textColor)')
77
80
  .text(marker.toString());
78
81
  this.zoom(1);
79
82
  const drag = d3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smithkit",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Framework-independent, interactive Smith chart library for JavaScript and TypeScript",
5
5
  "type": "module",
6
6
  "scripts": {