smithkit 0.3.0 → 0.5.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 (45) hide show
  1. package/README.md +324 -37
  2. package/dist/lib/OptionsValidation.d.ts +16 -0
  3. package/dist/lib/OptionsValidation.js +172 -0
  4. package/dist/lib/Smith.d.ts +20 -5
  5. package/dist/lib/Smith.js +114 -15
  6. package/dist/lib/appearance/Theme.js +5 -0
  7. package/dist/lib/grid/CircleLayerControl.d.ts +1 -1
  8. package/dist/lib/grid/CircleLayerControl.js +10 -8
  9. package/dist/lib/grid/ConstQCircles.d.ts +1 -0
  10. package/dist/lib/grid/ConstQCircles.js +5 -0
  11. package/dist/lib/grid/ConstSwrCircles.d.ts +1 -0
  12. package/dist/lib/grid/ConstSwrCircles.js +5 -0
  13. package/dist/lib/grid/GridLayerControl.js +5 -4
  14. package/dist/lib/grid/LayerControl.d.ts +1 -2
  15. package/dist/lib/grid/LayerControl.js +1 -10
  16. package/dist/lib/index.d.ts +3 -2
  17. package/dist/lib/io/Touchstone.d.ts +13 -7
  18. package/dist/lib/io/Touchstone.js +146 -46
  19. package/dist/lib/io/types.d.ts +17 -0
  20. package/dist/lib/io/types.js +1 -0
  21. package/dist/lib/layers.d.ts +7 -1
  22. package/dist/lib/measurements.d.ts +2 -0
  23. package/dist/lib/options.d.ts +58 -0
  24. package/dist/lib/options.js +1 -0
  25. package/dist/lib/rendering/SvgChartRenderer.d.ts +6 -3
  26. package/dist/lib/rendering/SvgChartRenderer.js +58 -11
  27. package/dist/lib/rf/RfCalculations.d.ts +3 -1
  28. package/dist/lib/rf/RfCalculations.js +23 -6
  29. package/dist/lib/samples.d.ts +5 -1
  30. package/dist/lib/scales/SmithPeripheralScales.d.ts +5 -0
  31. package/dist/lib/scales/SmithPeripheralScales.js +20 -1
  32. package/dist/lib/svg/SvgExporter.d.ts +1 -1
  33. package/dist/lib/svg/SvgExporter.js +4 -3
  34. package/dist/lib/svg/export.d.ts +3 -1
  35. package/dist/lib/traces/SmithData.d.ts +10 -4
  36. package/dist/lib/traces/SmithData.js +14 -4
  37. package/dist/lib/traces/TraceBuffer.d.ts +19 -0
  38. package/dist/lib/traces/TraceBuffer.js +176 -0
  39. package/dist/lib/traces/TraceLineDetail.d.ts +8 -0
  40. package/dist/lib/traces/TraceLineDetail.js +52 -0
  41. package/dist/lib/traces/TraceModel.d.ts +6 -8
  42. package/dist/lib/traces/TraceModel.js +24 -49
  43. package/dist/lib/traces/TraceRenderer.d.ts +13 -3
  44. package/dist/lib/traces/TraceRenderer.js +83 -23
  45. package/package.json +4 -2
@@ -6,6 +6,8 @@ const chartRadius = 250;
6
6
  const groupWidth = 40;
7
7
  /** Peripheral rulers in the chart's 500-unit coordinate system. */
8
8
  export class SmithPeripheralScales extends SmithGroup {
9
+ captionsVisible = true;
10
+ tickLabelsVisible = true;
9
11
  static point(radius, degrees) {
10
12
  const angle = (degrees * Math.PI) / 180;
11
13
  return [250 + radius * Math.cos(angle), 250 - radius * Math.sin(angle)];
@@ -79,11 +81,13 @@ export class SmithPeripheralScales extends SmithGroup {
79
81
  .attr('stroke-width', major ? 0.8 : 0.5)
80
82
  .attr('vector-effect', 'non-scaling-stroke');
81
83
  const captionDistance = Math.abs(((angle - captionAngle + 540) % 360) - 180);
82
- if (major && captionDistance > 25) {
84
+ if (major) {
83
85
  const [x, y] = SmithPeripheralScales.point(radius + side * 11, angle);
84
86
  const rotation = angle >= 0 ? 90 - angle : -90 - angle;
85
87
  labelAxis
86
88
  .append('text')
89
+ .attr('class', 'peripheral-tick-label')
90
+ .classed('peripheral-caption-sector', captionDistance <= 25)
87
91
  .attr('x', x)
88
92
  .attr('y', y)
89
93
  .attr('transform', `rotate(${rotation},${x},${y})`)
@@ -132,6 +136,21 @@ export class SmithPeripheralScales extends SmithGroup {
132
136
  .attr('stroke', 'var(--smithkit-scales-indicatorOutline)')
133
137
  .attr('stroke-width', 1);
134
138
  });
139
+ this.updateLabelVisibility();
140
+ }
141
+ setCaptionsVisible(visible) {
142
+ this.captionsVisible = visible;
143
+ this.updateLabelVisibility();
144
+ }
145
+ setTickLabelsVisible(visible) {
146
+ this.tickLabelsVisible = visible;
147
+ this.updateLabelVisibility();
148
+ }
149
+ updateLabelVisibility() {
150
+ this.labels.Element.selectAll('.peripheral-caption').attr('display', this.captionsVisible ? null : 'none');
151
+ this.labels.Element.selectAll('.peripheral-tick-label').attr('display', this.tickLabelsVisible ? null : 'none');
152
+ // Fill the reserved caption sectors with numeric labels when captions are hidden.
153
+ this.labels.Element.selectAll('.peripheral-caption-sector').attr('display', this.tickLabelsVisible && !this.captionsVisible ? null : 'none');
135
154
  }
136
155
  show() {
137
156
  this.labels.show();
@@ -4,7 +4,7 @@ export declare class SvgExporter {
4
4
  private static readonly namespace;
5
5
  private static readonly properties;
6
6
  private static readonly nonInherited;
7
- static chart(source: SVGElement): string;
7
+ static chart(source: SVGElement, customize?: (original: SVGElement, copy: SVGElement) => void): string;
8
8
  /** Preserve the current CSS layout of separate scale SVGs in one SVG document. */
9
9
  static scales(container: HTMLElement, axes: readonly SVGSVGElement[], customize?: (copy: SVGElement, index: number) => void, caption?: {
10
10
  text: string;
@@ -46,9 +46,9 @@ export class SvgExporter {
46
46
  'baseline-shift',
47
47
  'text-decoration',
48
48
  ]);
49
- static chart(source) {
49
+ static chart(source, customize) {
50
50
  const bounds = SvgExporter.bounds(source);
51
- const copy = SvgExporter.copy(source);
51
+ const copy = SvgExporter.copy(source, customize);
52
52
  SvgExporter.background(copy, source);
53
53
  SvgExporter.size(copy, bounds.width, bounds.height);
54
54
  return SvgExporter.serialize(copy);
@@ -120,7 +120,7 @@ export class SvgExporter {
120
120
  }
121
121
  return bounds;
122
122
  }
123
- static copy(source) {
123
+ static copy(source, customize) {
124
124
  const copy = source.cloneNode(true);
125
125
  const originals = [source, ...source.querySelectorAll('*')];
126
126
  const copies = [copy, ...copy.querySelectorAll('*')];
@@ -167,6 +167,7 @@ export class SvgExporter {
167
167
  element.removeAttribute(attribute.name);
168
168
  }
169
169
  }
170
+ customize?.(original, element);
170
171
  });
171
172
  return copy;
172
173
  }
@@ -25,12 +25,14 @@ export interface MarkerLegendOptions {
25
25
  fields?: readonly MarkerLegendField[];
26
26
  }
27
27
  export interface SmithImageExportOptions extends ImageExportOptions {
28
+ /** Full samples (default) or current displayed line geometry. View detail scales with export dimensions. */
29
+ lineDetail?: 'full' | 'view';
28
30
  /** Include a footer with the names and colors of visible traces. Default: false. */
29
31
  legend?: boolean;
30
32
  /** Include a marker legend below the chart/scales, independently of the trace legend. Default: false. */
31
33
  markerLegend?: boolean | MarkerLegendOptions;
32
34
  /** Include mounted radial scales centered below the chart, preserving their current layout/readings. */
33
35
  scales?: SmithScales;
34
- /** Override readings in the included radial scales without changing the live view. */
36
+ /** Override readings in the included radial scales without changing the live view. Requires scales. */
35
37
  scaleReadout?: ScaleExportReadout;
36
38
  }
@@ -2,7 +2,8 @@ import { SmithGroup } from '../svg/SmithGroup.js';
2
2
  import type { TraceStyle, MarkerSelectionStrategy } from '../measurements.js';
3
3
  import { SmithMarker } from './SmithMarker.js';
4
4
  import { SmithScaler } from '../svg/SmithScaler.js';
5
- import type { TraceSamples, TraceSample } from '../samples.js';
5
+ import type { TraceInput, TraceSample } from '../samples.js';
6
+ import { TraceBuffer } from './TraceBuffer.js';
6
7
  import { type TraceTransform } from './TraceRenderer.js';
7
8
  /** Coordinates trace rendering, draggable markers, and deferred marker events. */
8
9
  export declare class SmithData {
@@ -21,19 +22,23 @@ export declare class SmithData {
21
22
  private selectHandler;
22
23
  private visible;
23
24
  private handler;
24
- constructor(data: TraceSamples, color: string, transform: TraceTransform, container: SmithGroup, scaler: SmithScaler, markerDragHandler?: ((marker: SmithMarker, dragging: boolean) => void) | undefined, markerContainer?: SmithGroup, style?: Partial<TraceStyle>);
25
+ constructor(data: TraceInput, color: string, transform: TraceTransform, container: SmithGroup, scaler: SmithScaler, markerDragHandler?: ((marker: SmithMarker, dragging: boolean) => void) | undefined, markerContainer?: SmithGroup, style?: Partial<TraceStyle>);
25
26
  setViewportScale(scale: number): void;
26
27
  zoom(transform: TraceTransform): void;
27
28
  addMarker(sampleIndex?: number): number;
28
29
  removeMarker(index: number): boolean;
29
30
  setMarkerSample(index: number, sampleIndex: number): boolean;
30
31
  setMarkerFrequency(index: number, frequencyHz: number): boolean;
31
- get Samples(): TraceSamples;
32
+ get Samples(): TraceBuffer;
32
33
  get SampleCount(): number;
33
34
  get Color(): string;
34
35
  get Visible(): boolean;
35
36
  markerSampleIndex(index: number): number;
36
37
  setColor(color: string): void;
38
+ fullExportLine(): {
39
+ node: SVGElement;
40
+ path: string;
41
+ } | undefined;
37
42
  get Style(): TraceStyle;
38
43
  setStyle(options: Partial<TraceStyle>): void;
39
44
  setVisible(visible: boolean): void;
@@ -43,10 +48,11 @@ export declare class SmithData {
43
48
  setMarkerSelectHandler(handler: (marker: SmithMarker) => void): void;
44
49
  private notifyMarker;
45
50
  private cancelEvents;
46
- update(values: TraceSamples, strategy?: MarkerSelectionStrategy): void;
51
+ update(values: TraceInput | TraceBuffer, strategy?: MarkerSelectionStrategy): void;
47
52
  destroy(): void;
48
53
  get Markers(): {
49
54
  number: number;
55
+ sampleIndex: number;
50
56
  selectedPoint: TraceSample;
51
57
  marker: SmithMarker;
52
58
  }[];
@@ -110,6 +110,9 @@ export class SmithData {
110
110
  this.renderer.setColor(color);
111
111
  this.markers.forEach((marker) => marker.setColor(color));
112
112
  }
113
+ fullExportLine() {
114
+ return this.renderer.fullExportLine();
115
+ }
113
116
  get Style() {
114
117
  return this.renderer.Style;
115
118
  }
@@ -129,12 +132,15 @@ export class SmithData {
129
132
  }
130
133
  moveMarker(entry) {
131
134
  const marker = this.markers.get(entry);
132
- marker.move(this.scaler.point(entry.selectedPoint.reflectionCoefficient));
135
+ marker.move(this.scaler.point([
136
+ this.model.Samples.real(entry.sampleIndex),
137
+ this.model.Samples.imaginary(entry.sampleIndex),
138
+ ]));
133
139
  this.updateReading(entry, marker);
134
140
  this.notifyMarker(entry);
135
141
  }
136
142
  updateReading(entry, marker) {
137
- marker.setReading(`${this.name}, marker ${entry.number}`, this.model.markerSampleIndex(this.model.markerIndex(entry)), this.SampleCount, entry.selectedPoint.frequencyHz);
143
+ marker.setReading(`${this.name}, marker ${entry.number}`, this.model.markerSampleIndex(this.model.markerIndex(entry)), this.SampleCount, this.model.Samples.frequency(entry.sampleIndex));
138
144
  }
139
145
  setName(name) {
140
146
  this.name = name;
@@ -151,7 +157,7 @@ export class SmithData {
151
157
  this.pendingEvents.delete(entry);
152
158
  const index = this.model.markerIndex(entry);
153
159
  if (!this.destroyed && index >= 0) {
154
- this.handler?.(index, entry.selectedPoint);
160
+ this.handler?.(index, this.model.Samples.sample(entry.sampleIndex));
155
161
  }
156
162
  }, 0);
157
163
  this.pendingEvents.set(entry, timer);
@@ -182,7 +188,11 @@ export class SmithData {
182
188
  this.renderer.destroy();
183
189
  }
184
190
  get Markers() {
185
- return this.model.Markers.map((entry) => ({ ...entry, marker: this.markers.get(entry) }));
191
+ return this.model.Markers.map((entry) => ({
192
+ ...entry,
193
+ selectedPoint: this.model.Samples.sample(entry.sampleIndex),
194
+ marker: this.markers.get(entry),
195
+ }));
186
196
  }
187
197
  setMarkerMoveHandler(handler) {
188
198
  this.handler = handler;
@@ -0,0 +1,19 @@
1
+ import type { TraceInput, TraceSample } from '../samples.js';
2
+ import type { Point } from '../math/geometry.js';
3
+ /** Owned, packed f/re/im storage. Never exposes the mutable backing buffer. */
4
+ export declare class TraceBuffer {
5
+ private readonly values;
6
+ private readonly sorted;
7
+ private constructor();
8
+ static from(input: TraceInput): TraceBuffer;
9
+ static empty(): TraceBuffer;
10
+ get length(): number;
11
+ frequency(index: number): number;
12
+ real(index: number): number;
13
+ imaginary(index: number): number;
14
+ sample(index: number): TraceSample;
15
+ nearestFrequency(frequencyHz: number): number;
16
+ private lowerBound;
17
+ nearestPoint(point: Readonly<Point>): number;
18
+ renormalize(fromOhms: number, toOhms: number): TraceBuffer;
19
+ }
@@ -0,0 +1,176 @@
1
+ import { Complex } from '../math/Complex.js';
2
+ import { RfCalculations } from '../rf/RfCalculations.js';
3
+ /** Owned, packed f/re/im storage. Never exposes the mutable backing buffer. */
4
+ export class TraceBuffer {
5
+ values;
6
+ sorted;
7
+ constructor(values, sorted) {
8
+ this.values = values;
9
+ this.sorted = sorted;
10
+ }
11
+ static from(input) {
12
+ const packed = input instanceof Float64Array;
13
+ if ((!packed && !Array.isArray(input)) || !input.length || (packed && input.length % 3 !== 0)) {
14
+ throw new RangeError('A trace requires complete samples with frequency, real, and imaginary values.');
15
+ }
16
+ const count = packed ? input.length / 3 : input.length;
17
+ const values = new Float64Array(count * 3);
18
+ const tuples = !packed && Array.isArray(input[0]);
19
+ let sorted = true;
20
+ for (let i = 0; i < count; i++) {
21
+ let f;
22
+ let re;
23
+ let im;
24
+ if (packed) {
25
+ f = input[i * 3];
26
+ re = input[i * 3 + 1];
27
+ im = input[i * 3 + 2];
28
+ }
29
+ else {
30
+ const sample = input[i];
31
+ if (tuples) {
32
+ if (!Array.isArray(sample) || sample.length !== 3) {
33
+ throw new RangeError(`Invalid tuple at sample ${i}; expected [frequencyHz, re, im].`);
34
+ }
35
+ [f, re, im] = sample;
36
+ }
37
+ else {
38
+ if (!sample ||
39
+ typeof sample !== 'object' ||
40
+ Array.isArray(sample) ||
41
+ !('reflectionCoefficient' in sample) ||
42
+ !Array.isArray(sample.reflectionCoefficient) ||
43
+ sample.reflectionCoefficient.length !== 2) {
44
+ throw new RangeError(`Invalid object at sample ${i}; expected frequencyHz and reflectionCoefficient.`);
45
+ }
46
+ f = sample.frequencyHz;
47
+ [re, im] = sample.reflectionCoefficient;
48
+ }
49
+ }
50
+ if (!Number.isFinite(f) || f < 0 || !Number.isFinite(re) || !Number.isFinite(im)) {
51
+ throw new RangeError(`Invalid sample at index ${i}; frequency must be non-negative and all values finite.`);
52
+ }
53
+ values[i * 3] = f;
54
+ values[i * 3 + 1] = re;
55
+ values[i * 3 + 2] = im;
56
+ if (i > 0 && f < values[(i - 1) * 3]) {
57
+ sorted = false;
58
+ }
59
+ }
60
+ return new TraceBuffer(values, sorted);
61
+ }
62
+ static empty() {
63
+ return new TraceBuffer(new Float64Array(), true);
64
+ }
65
+ get length() {
66
+ return this.values.length / 3;
67
+ }
68
+ frequency(index) {
69
+ return this.values[index * 3];
70
+ }
71
+ real(index) {
72
+ return this.values[index * 3 + 1];
73
+ }
74
+ imaginary(index) {
75
+ return this.values[index * 3 + 2];
76
+ }
77
+ sample(index) {
78
+ return {
79
+ frequencyHz: this.frequency(index),
80
+ reflectionCoefficient: [this.real(index), this.imaginary(index)],
81
+ };
82
+ }
83
+ nearestFrequency(frequencyHz) {
84
+ if (this.sorted) {
85
+ const right = this.lowerBound(frequencyHz);
86
+ if (right === 0) {
87
+ return 0;
88
+ }
89
+ const distance = frequencyHz - this.frequency(right - 1);
90
+ if (right < this.length && this.frequency(right) - frequencyHz < distance) {
91
+ return right;
92
+ }
93
+ // Find the earliest equally close sample, including floating-point rounding
94
+ // ties between distinct frequencies far below the requested frequency.
95
+ let low = 0;
96
+ let high = right - 1;
97
+ while (low < high) {
98
+ const mid = Math.floor((low + high) / 2);
99
+ if (frequencyHz - this.frequency(mid) > distance) {
100
+ low = mid + 1;
101
+ }
102
+ else {
103
+ high = mid;
104
+ }
105
+ }
106
+ return low;
107
+ }
108
+ let closest = 0;
109
+ let distance = Math.abs(this.frequency(0) - frequencyHz);
110
+ for (let i = 1; i < this.length; i++) {
111
+ const next = Math.abs(this.frequency(i) - frequencyHz);
112
+ if (next < distance) {
113
+ closest = i;
114
+ distance = next;
115
+ }
116
+ }
117
+ return closest;
118
+ }
119
+ lowerBound(frequencyHz) {
120
+ let low = 0;
121
+ let high = this.length;
122
+ while (low < high) {
123
+ const mid = Math.floor((low + high) / 2);
124
+ if (this.frequency(mid) < frequencyHz) {
125
+ low = mid + 1;
126
+ }
127
+ else {
128
+ high = mid;
129
+ }
130
+ }
131
+ return low;
132
+ }
133
+ nearestPoint(point) {
134
+ let closest = 0;
135
+ let distance = Infinity;
136
+ const [real, imaginary] = point;
137
+ // A coordinate difference is a lower bound on Euclidean distance. Reject
138
+ // points outside the current bounding square before evaluating the norm.
139
+ // Keep the boundary inclusive and compare surviving norms with Math.hypot
140
+ // to preserve first-input ties, including ties caused by floating-point rounding.
141
+ for (let offset = 1; offset < this.values.length; offset += 3) {
142
+ const dx = real - this.values[offset];
143
+ if (Math.abs(dx) > distance) {
144
+ continue;
145
+ }
146
+ const dy = imaginary - this.values[offset + 1];
147
+ if (Math.abs(dy) > distance) {
148
+ continue;
149
+ }
150
+ const next = Math.hypot(dx, dy);
151
+ if (next < distance) {
152
+ closest = (offset - 1) / 3;
153
+ distance = next;
154
+ if (distance === 0) {
155
+ return closest;
156
+ }
157
+ }
158
+ }
159
+ return closest;
160
+ }
161
+ renormalize(fromOhms, toOhms) {
162
+ RfCalculations.renormalizeSamples([], fromOhms, toOhms);
163
+ const values = new Float64Array(this.values.length);
164
+ for (let i = 0; i < this.length; i++) {
165
+ const gamma = RfCalculations.renormalizeReflection(Complex.from(this.real(i), this.imaginary(i)), fromOhms, toOhms);
166
+ if (!gamma) {
167
+ throw new RangeError(`Renormalization is singular or outside the numeric range at sample ${i}.`);
168
+ }
169
+ values[i * 3] = this.frequency(i);
170
+ const [re, im] = gamma.toVector();
171
+ values[i * 3 + 1] = re;
172
+ values[i * 3 + 2] = im;
173
+ }
174
+ return new TraceBuffer(values, this.sorted);
175
+ }
176
+ }
@@ -0,0 +1,8 @@
1
+ import { TraceBuffer } from './TraceBuffer.js';
2
+ /** Nested radial-distance levels with a conservative accumulated error in Γ units. */
3
+ export declare class TraceLineDetail {
4
+ private readonly levels;
5
+ constructor(data: TraceBuffer);
6
+ /** Undefined means use every sample. No data scan or allocation during view selection. */
7
+ select(tolerance: number): Uint32Array | undefined;
8
+ }
@@ -0,0 +1,52 @@
1
+ /** Nested radial-distance levels with a conservative accumulated error in Γ units. */
2
+ export class TraceLineDetail {
3
+ levels = [];
4
+ constructor(data) {
5
+ if (data.length < 3) {
6
+ return;
7
+ }
8
+ // Reuse one scratch buffer; retained levels must shrink by at least 20%.
9
+ // This bounds cached index storage by a geometric series instead of levels × samples.
10
+ const scratch = new Uint32Array(data.length);
11
+ let previous;
12
+ let previousError = 0;
13
+ for (let exponent = -20; exponent <= 0; exponent++) {
14
+ const error = 2 ** exponent;
15
+ const allowance = error - previousError;
16
+ const squared = allowance * allowance;
17
+ const length = previous?.length ?? data.length;
18
+ let count = 1;
19
+ let last = 0;
20
+ scratch[0] = 0;
21
+ for (let position = 1; position < length - 1; position++) {
22
+ const index = previous ? previous[position] : position;
23
+ const dx = data.real(index) - data.real(last);
24
+ const dy = data.imaginary(index) - data.imaginary(last);
25
+ if (dx * dx + dy * dy > squared) {
26
+ scratch[count++] = index;
27
+ last = index;
28
+ }
29
+ }
30
+ scratch[count++] = data.length - 1;
31
+ if (count <= length * 0.8) {
32
+ previous = scratch.slice(0, count);
33
+ previousError = error;
34
+ this.levels.push({ error, indices: previous });
35
+ }
36
+ if (count === 2) {
37
+ break;
38
+ }
39
+ }
40
+ }
41
+ /** Undefined means use every sample. No data scan or allocation during view selection. */
42
+ select(tolerance) {
43
+ let indices;
44
+ for (const level of this.levels) {
45
+ if (level.error > tolerance) {
46
+ break;
47
+ }
48
+ indices = level.indices;
49
+ }
50
+ return indices;
51
+ }
52
+ }
@@ -1,17 +1,18 @@
1
1
  import type { MarkerSelectionStrategy } from '../measurements.js';
2
2
  import type { Point } from '../math/geometry.js';
3
- import type { TraceSample, TraceSamples } from '../samples.js';
3
+ import type { TraceInput } from '../samples.js';
4
+ import { TraceBuffer } from './TraceBuffer.js';
4
5
  export interface TraceMarker {
5
6
  readonly number: number;
6
- selectedPoint: TraceSample;
7
+ sampleIndex: number;
7
8
  }
8
9
  /** Owns validated samples and marker selections without depending on SVG or D3. */
9
10
  export declare class TraceModel {
10
11
  private samples;
11
12
  private markers;
12
13
  private markerCount;
13
- constructor(samples: TraceSamples);
14
- get Samples(): TraceSamples;
14
+ constructor(samples: TraceInput);
15
+ get Samples(): TraceBuffer;
15
16
  get Markers(): readonly TraceMarker[];
16
17
  addMarker(sampleIndex: number): TraceMarker;
17
18
  removeMarker(index: number): TraceMarker | undefined;
@@ -22,10 +23,7 @@ export declare class TraceModel {
22
23
  setMarkerFrequency(index: number, frequencyHz: number): TraceMarker | undefined;
23
24
  /** Returns false when dragging still selects the same sample. */
24
25
  selectNearestPoint(marker: TraceMarker, point: Readonly<Point>): boolean;
25
- update(values: TraceSamples, strategy: MarkerSelectionStrategy): void;
26
+ update(values: TraceInput | TraceBuffer, strategy: MarkerSelectionStrategy): void;
26
27
  clear(): void;
27
- private nearestFrequencyIndex;
28
- private nearestPoint;
29
28
  private validateSampleIndex;
30
- private copySamples;
31
29
  }
@@ -1,10 +1,11 @@
1
+ import { TraceBuffer } from './TraceBuffer.js';
1
2
  /** Owns validated samples and marker selections without depending on SVG or D3. */
2
3
  export class TraceModel {
3
4
  samples;
4
5
  markers = [];
5
6
  markerCount = 0;
6
7
  constructor(samples) {
7
- this.samples = this.copySamples(samples);
8
+ this.samples = TraceBuffer.from(samples);
8
9
  }
9
10
  get Samples() {
10
11
  return this.samples;
@@ -14,7 +15,7 @@ export class TraceModel {
14
15
  }
15
16
  addMarker(sampleIndex) {
16
17
  this.validateSampleIndex(sampleIndex);
17
- const marker = { number: ++this.markerCount, selectedPoint: this.samples[sampleIndex] };
18
+ const marker = { number: ++this.markerCount, sampleIndex };
18
19
  this.markers.push(marker);
19
20
  return marker;
20
21
  }
@@ -33,7 +34,7 @@ export class TraceModel {
33
34
  }
34
35
  markerSampleIndex(index) {
35
36
  const marker = this.markers[index];
36
- return marker ? this.samples.indexOf(marker.selectedPoint) : -1;
37
+ return marker?.sampleIndex ?? -1;
37
38
  }
38
39
  setMarkerSample(index, sampleIndex) {
39
40
  const marker = this.markers[index];
@@ -41,7 +42,7 @@ export class TraceModel {
41
42
  return;
42
43
  }
43
44
  this.validateSampleIndex(sampleIndex);
44
- marker.selectedPoint = this.samples[sampleIndex];
45
+ marker.sampleIndex = sampleIndex;
45
46
  return marker;
46
47
  }
47
48
  setMarkerFrequency(index, frequencyHz) {
@@ -51,73 +52,47 @@ export class TraceModel {
51
52
  if (!Number.isFinite(frequencyHz) || frequencyHz < 0) {
52
53
  throw new RangeError('Marker frequency must be finite and non-negative.');
53
54
  }
54
- return this.setMarkerSample(index, this.nearestFrequencyIndex(frequencyHz));
55
+ return this.setMarkerSample(index, this.samples.nearestFrequency(frequencyHz));
55
56
  }
56
57
  /** Returns false when dragging still selects the same sample. */
57
58
  selectNearestPoint(marker, point) {
58
59
  if (!this.markers.includes(marker)) {
59
60
  return false;
60
61
  }
61
- const sample = this.nearestPoint(point);
62
- if (sample === marker.selectedPoint) {
62
+ const sampleIndex = this.samples.nearestPoint(point);
63
+ if (sampleIndex === marker.sampleIndex) {
63
64
  return false;
64
65
  }
65
- marker.selectedPoint = sample;
66
+ marker.sampleIndex = sampleIndex;
66
67
  return true;
67
68
  }
68
69
  update(values, strategy) {
69
- // Validate and copy before mutating either samples or marker selections.
70
- const samples = this.copySamples(values);
71
- const selectedIndices = this.markers.map((entry) => this.samples.indexOf(entry.selectedPoint));
72
- this.samples = samples;
73
- this.markers.forEach((entry, index) => {
70
+ // Prepare every selection before replacing data so rejected updates are atomic.
71
+ const samples = values instanceof TraceBuffer ? values : TraceBuffer.from(values);
72
+ const selectedIndices = this.markers.map((entry) => {
74
73
  if (strategy === 'sample-index') {
75
- entry.selectedPoint = samples[Math.min(selectedIndices[index], samples.length - 1)];
76
- }
77
- else if (strategy === 'reflection') {
78
- entry.selectedPoint = this.nearestPoint(entry.selectedPoint.reflectionCoefficient);
74
+ return Math.min(entry.sampleIndex, samples.length - 1);
79
75
  }
80
- else {
81
- entry.selectedPoint = samples[this.nearestFrequencyIndex(entry.selectedPoint.frequencyHz)];
76
+ if (strategy === 'reflection') {
77
+ return samples.nearestPoint([
78
+ this.samples.real(entry.sampleIndex),
79
+ this.samples.imaginary(entry.sampleIndex),
80
+ ]);
82
81
  }
82
+ return samples.nearestFrequency(this.samples.frequency(entry.sampleIndex));
83
+ });
84
+ this.samples = samples;
85
+ this.markers.forEach((entry, index) => {
86
+ entry.sampleIndex = selectedIndices[index];
83
87
  });
84
88
  }
85
89
  clear() {
86
- this.samples = [];
90
+ this.samples = TraceBuffer.empty();
87
91
  this.markers = [];
88
92
  }
89
- nearestFrequencyIndex(frequencyHz) {
90
- let closest = 0;
91
- let distance = Math.abs(this.samples[0].frequencyHz - frequencyHz);
92
- for (let i = 1; i < this.samples.length; i++) {
93
- const nextDistance = Math.abs(this.samples[i].frequencyHz - frequencyHz);
94
- if (nextDistance < distance) {
95
- closest = i;
96
- distance = nextDistance;
97
- }
98
- }
99
- return closest;
100
- }
101
- nearestPoint(point) {
102
- const distance = (sample) => Math.hypot(point[0] - sample.reflectionCoefficient[0], point[1] - sample.reflectionCoefficient[1]);
103
- return this.samples.reduce((previous, current) => distance(previous) <= distance(current) ? previous : current);
104
- }
105
93
  validateSampleIndex(index) {
106
94
  if (!Number.isInteger(index) || index < 0 || index >= this.samples.length) {
107
95
  throw new RangeError('Sample index is outside this trace.');
108
96
  }
109
97
  }
110
- copySamples(values) {
111
- if (!values.length ||
112
- values.some(({ frequencyHz, reflectionCoefficient }) => !Number.isFinite(frequencyHz) ||
113
- frequencyHz < 0 ||
114
- reflectionCoefficient.length !== 2 ||
115
- !reflectionCoefficient.every(Number.isFinite))) {
116
- throw new RangeError('A dataset requires samples with a non-negative finite frequency and two finite coordinates.');
117
- }
118
- return values.map(({ frequencyHz, reflectionCoefficient }) => ({
119
- frequencyHz,
120
- reflectionCoefficient: [reflectionCoefficient[0], reflectionCoefficient[1]],
121
- }));
122
- }
123
98
  }
@@ -1,7 +1,7 @@
1
1
  import { SmithGroup } from '../svg/SmithGroup.js';
2
2
  import { SmithScaler } from '../svg/SmithScaler.js';
3
3
  import type { TraceStyle } from '../measurements.js';
4
- import type { TraceSamples } from '../samples.js';
4
+ import { TraceBuffer } from './TraceBuffer.js';
5
5
  export interface TraceTransform {
6
6
  x: number;
7
7
  y: number;
@@ -17,8 +17,18 @@ export declare class TraceRenderer {
17
17
  private viewportScale;
18
18
  private visible;
19
19
  private group;
20
- constructor(data: TraceSamples, color: string, transform: TraceTransform, container: SmithGroup, scaler: SmithScaler, style?: Partial<TraceStyle>);
20
+ private lineDetail;
21
+ private lineIndices;
22
+ constructor(data: TraceBuffer, color: string, transform: TraceTransform, container: SmithGroup, scaler: SmithScaler, style?: Partial<TraceStyle>);
21
23
  private drawTrace;
24
+ private selectLineIndices;
25
+ private linePath;
26
+ private updateLine;
27
+ /** Replace geometry only in the export copy, never in the live SVG. */
28
+ fullExportLine(): {
29
+ node: SVGElement;
30
+ path: string;
31
+ } | undefined;
22
32
  private renderPoints;
23
33
  setViewportScale(scale: number): void;
24
34
  zoom(transform: TraceTransform): void;
@@ -28,6 +38,6 @@ export declare class TraceRenderer {
28
38
  setStyle(options: Partial<TraceStyle>): void;
29
39
  private redraw;
30
40
  setVisible(visible: boolean): void;
31
- update(data: TraceSamples): void;
41
+ update(data: TraceBuffer): void;
32
42
  destroy(): void;
33
43
  }