@stocksharp/trading-controls 1.2.0 → 1.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.
- package/README.md +204 -17
- package/dist/esm/black-scholes.js +147 -0
- package/dist/esm/black-scholes.js.map +1 -0
- package/dist/esm/chart-engine.js +15 -0
- package/dist/esm/chart-engine.js.map +1 -0
- package/dist/esm/control-types.js +8 -0
- package/dist/esm/control-types.js.map +1 -1
- package/dist/esm/equity-widget.js +261 -0
- package/dist/esm/equity-widget.js.map +1 -0
- package/dist/esm/heatmap-grid.js +271 -0
- package/dist/esm/heatmap-grid.js.map +1 -0
- package/dist/esm/index.js +37 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/log-monitor-widget.js +265 -0
- package/dist/esm/log-monitor-widget.js.map +1 -0
- package/dist/esm/log-tree.js +96 -0
- package/dist/esm/log-tree.js.map +1 -0
- package/dist/esm/optimization-heatmap-widget.js +303 -0
- package/dist/esm/optimization-heatmap-widget.js.map +1 -0
- package/dist/esm/option-desk-widget.js +322 -0
- package/dist/esm/option-desk-widget.js.map +1 -0
- package/dist/esm/option-smile-widget.js +252 -0
- package/dist/esm/option-smile-widget.js.map +1 -0
- package/dist/esm/pnl-curve.js +133 -0
- package/dist/esm/pnl-curve.js.map +1 -0
- package/dist/esm/statistics-widget.js +194 -0
- package/dist/esm/statistics-widget.js.map +1 -0
- package/dist/esm/strategies-widget.js +348 -0
- package/dist/esm/strategies-widget.js.map +1 -0
- package/dist/esm/surface-grid.js +282 -0
- package/dist/esm/surface-grid.js.map +1 -0
- package/dist/esm/surface-widget.js +379 -0
- package/dist/esm/surface-widget.js.map +1 -0
- package/dist/sstradingcontrols.js +2982 -295
- package/dist/sstradingcontrols.js.map +4 -4
- package/dist/types/black-scholes.d.ts +28 -0
- package/dist/types/black-scholes.d.ts.map +1 -0
- package/dist/types/chart-engine.d.ts +3 -0
- package/dist/types/chart-engine.d.ts.map +1 -0
- package/dist/types/control-types.d.ts +8 -0
- package/dist/types/control-types.d.ts.map +1 -1
- package/dist/types/equity-widget.d.ts +34 -0
- package/dist/types/equity-widget.d.ts.map +1 -0
- package/dist/types/heatmap-grid.d.ts +88 -0
- package/dist/types/heatmap-grid.d.ts.map +1 -0
- package/dist/types/index.d.ts +31 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/log-monitor-widget.d.ts +42 -0
- package/dist/types/log-monitor-widget.d.ts.map +1 -0
- package/dist/types/log-tree.d.ts +34 -0
- package/dist/types/log-tree.d.ts.map +1 -0
- package/dist/types/optimization-heatmap-widget.d.ts +45 -0
- package/dist/types/optimization-heatmap-widget.d.ts.map +1 -0
- package/dist/types/option-desk-widget.d.ts +68 -0
- package/dist/types/option-desk-widget.d.ts.map +1 -0
- package/dist/types/option-smile-widget.d.ts +39 -0
- package/dist/types/option-smile-widget.d.ts.map +1 -0
- package/dist/types/pnl-curve.d.ts +46 -0
- package/dist/types/pnl-curve.d.ts.map +1 -0
- package/dist/types/statistics-widget.d.ts +29 -0
- package/dist/types/statistics-widget.d.ts.map +1 -0
- package/dist/types/strategies-widget.d.ts +63 -0
- package/dist/types/strategies-widget.d.ts.map +1 -0
- package/dist/types/surface-grid.d.ts +64 -0
- package/dist/types/surface-grid.d.ts.map +1 -0
- package/dist/types/surface-widget.d.ts +64 -0
- package/dist/types/surface-widget.d.ts.map +1 -0
- package/dist/types/trading-data.d.ts +9 -0
- package/dist/types/trading-data.d.ts.map +1 -1
- package/package.json +54 -3
- package/screenshots/equity.png +0 -0
- package/screenshots/log-monitor.png +0 -0
- package/screenshots/optimization-surface.png +0 -0
- package/screenshots/optimization.png +0 -0
- package/screenshots/option-desk.png +0 -0
- package/screenshots/option-smile.png +0 -0
- package/screenshots/panels.jpg +0 -0
- package/screenshots/statistics.png +0 -0
- package/screenshots/strategies.png +0 -0
- package/src/black-scholes.ts +199 -0
- package/src/chart-engine.global.ts +64 -0
- package/src/chart-engine.ts +29 -0
- package/src/control-types.ts +8 -0
- package/src/equity-widget.ts +289 -0
- package/src/heatmap-grid.ts +405 -0
- package/src/index.ts +87 -0
- package/src/log-monitor-widget.ts +312 -0
- package/src/log-tree.ts +131 -0
- package/src/optimization-heatmap-widget.ts +347 -0
- package/src/option-desk-widget.ts +422 -0
- package/src/option-smile-widget.ts +281 -0
- package/src/pnl-curve.ts +216 -0
- package/src/statistics-widget.ts +226 -0
- package/src/strategies-widget.ts +435 -0
- package/src/surface-grid.ts +410 -0
- package/src/surface-widget.ts +457 -0
- package/src/trading-data.ts +22 -0
- package/styles/trading-controls.css +637 -0
- package/translation-keys.json +83 -1
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// The geometry of a 3D surface over a grid, as pure functions over numbers.
|
|
2
|
+
//
|
|
3
|
+
// The same measurements the heatmap lays out flat, lifted: one metric over two discrete axes,
|
|
4
|
+
// with the metric as height instead of as colour. It reads the same `HeatCell[]` and folds them
|
|
5
|
+
// the same way, so a consumer that can draw one can draw the other from the identical input.
|
|
6
|
+
//
|
|
7
|
+
// There is no library under this. A surface is a grid of quads, a rotation is two angles, and a
|
|
8
|
+
// projection is six multiplications - all of it arithmetic, which is why it lives here and can be
|
|
9
|
+
// checked against numbers rather than against pixels. What the canvas does with the result is
|
|
10
|
+
// stroke and fill, nothing more.
|
|
11
|
+
//
|
|
12
|
+
// Orthographic rather than perspective. A surface is read by comparing heights across it, and
|
|
13
|
+
// perspective makes the far side of a ridge shorter than the near side of the same ridge - the
|
|
14
|
+
// one comparison the picture exists to support is the one it would distort.
|
|
15
|
+
import { foldCells, heatScale, tintOf, type HeatBucket, type HeatCell, type HeatDirection, type HeatScale } from './heatmap-grid.js';
|
|
16
|
+
|
|
17
|
+
/// Where the surface is looked at from.
|
|
18
|
+
export interface SurfaceView {
|
|
19
|
+
/// Rotation about the vertical axis, in radians. Wraps.
|
|
20
|
+
yaw: number;
|
|
21
|
+
/// Tilt, in radians. Clamped: at zero the surface is edge-on and every cell is a line, and
|
|
22
|
+
/// past a right angle the picture flips under itself.
|
|
23
|
+
pitch: number;
|
|
24
|
+
/// How much of the box the surface fills. 1 is fitted; larger magnifies.
|
|
25
|
+
zoom: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// The box to draw in, in CSS pixels.
|
|
29
|
+
export interface SurfaceBox {
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// One cell of the surface, as four projected corners.
|
|
35
|
+
export interface SurfaceQuad {
|
|
36
|
+
/// The corners, in draw order.
|
|
37
|
+
points: [number, number][];
|
|
38
|
+
/// Where the cell's value sits against the scale's anchor, -1..1 by `betterWhen` - what the
|
|
39
|
+
/// fill is tinted by, so the surface is coloured exactly the way the flat map is.
|
|
40
|
+
tint: number;
|
|
41
|
+
/// How far from the viewer, for painter ordering. Larger is nearer.
|
|
42
|
+
depth: number;
|
|
43
|
+
/// What this cell is, so a caption can name it without a second lookup.
|
|
44
|
+
bucket: HeatBucket;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// One edge of the floor, projected - the frame that says which way the grid runs.
|
|
48
|
+
export interface SurfaceAxis {
|
|
49
|
+
from: [number, number];
|
|
50
|
+
to: [number, number];
|
|
51
|
+
/// Which axis this edge belongs to, so a consumer can label it. `z` is the vertical one, the
|
|
52
|
+
/// scale the surface is measured against.
|
|
53
|
+
axis: 'x' | 'y' | 'z';
|
|
54
|
+
/// Where the ticks fall along it, already projected.
|
|
55
|
+
ticks: SurfaceTick[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// One mark on an axis: where it lands on screen, what it says, and which way to push the text so
|
|
59
|
+
/// it sits outside the floor rather than on it.
|
|
60
|
+
export interface SurfaceTick {
|
|
61
|
+
at: [number, number];
|
|
62
|
+
/// The value, worded by whoever built the layout: a parameter as it was swept, a height as the
|
|
63
|
+
/// metric it measures.
|
|
64
|
+
label: string;
|
|
65
|
+
/// Unit vector pointing away from the surface, for the tick stroke and the text offset.
|
|
66
|
+
away: [number, number];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// One measured point of the grid, where it landed and what it is worth. The readout under a
|
|
70
|
+
/// pointer is found here rather than by inverting the projection: a projection that flattens three
|
|
71
|
+
/// dimensions into two has no single inverse, and the nearest projected vertex is the answer a
|
|
72
|
+
/// reader means anyway.
|
|
73
|
+
export interface SurfaceVertex {
|
|
74
|
+
at: [number, number];
|
|
75
|
+
x: string;
|
|
76
|
+
y: string;
|
|
77
|
+
value: number;
|
|
78
|
+
/// Nearer the viewer is larger, so two vertices over the same pixel resolve to the front one.
|
|
79
|
+
depth: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SurfaceLayout {
|
|
83
|
+
/// Far to near: painted in order, nearer cells cover farther ones.
|
|
84
|
+
quads: SurfaceQuad[];
|
|
85
|
+
axes: SurfaceAxis[];
|
|
86
|
+
/// Every measured point, for the readout under a pointer.
|
|
87
|
+
vertices: SurfaceVertex[];
|
|
88
|
+
/// The values each axis steps through, in the order the grid uses them.
|
|
89
|
+
xValues: string[];
|
|
90
|
+
yValues: string[];
|
|
91
|
+
scale: HeatScale;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SurfaceInput {
|
|
95
|
+
width: number;
|
|
96
|
+
height: number;
|
|
97
|
+
cells: readonly HeatCell[];
|
|
98
|
+
betterWhen: HeatDirection;
|
|
99
|
+
view: SurfaceView;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// The tilt the surface starts at, and the bounds a drag may take it to.
|
|
103
|
+
///
|
|
104
|
+
/// Not zero and not a right angle: at zero every cell collapses to a line, and at a right angle
|
|
105
|
+
/// the surface is the flat map, which is the other control. The default sits where a ridge reads
|
|
106
|
+
/// as a ridge.
|
|
107
|
+
export const MIN_PITCH = 0.12;
|
|
108
|
+
export const MAX_PITCH = 1.45;
|
|
109
|
+
export const DEFAULT_VIEW: SurfaceView = { yaw: -0.7, pitch: 0.62, zoom: 1 };
|
|
110
|
+
|
|
111
|
+
/// How far a drag turns the surface, in radians per CSS pixel. Slow enough that a phone's thumb
|
|
112
|
+
/// can land on a face, fast enough that a mouse crosses the whole rotation in one sweep.
|
|
113
|
+
const DRAG_TO_RADIANS = 0.008;
|
|
114
|
+
|
|
115
|
+
const MIN_ZOOM = 0.4;
|
|
116
|
+
const MAX_ZOOM = 4;
|
|
117
|
+
|
|
118
|
+
/// A view kept inside what can be drawn.
|
|
119
|
+
export function clampView(view: SurfaceView): SurfaceView {
|
|
120
|
+
const twoPi = Math.PI * 2;
|
|
121
|
+
return {
|
|
122
|
+
// Wrapped rather than clamped: turning the surface right round is a gesture a reader
|
|
123
|
+
// makes on purpose, to see the far side of a ridge.
|
|
124
|
+
yaw: ((view.yaw % twoPi) + twoPi) % twoPi,
|
|
125
|
+
pitch: Math.min(MAX_PITCH, Math.max(MIN_PITCH, view.pitch)),
|
|
126
|
+
zoom: Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, view.zoom)),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// The view a drag of this many pixels leaves behind.
|
|
131
|
+
export function dragView(view: SurfaceView, dx: number, dy: number): SurfaceView {
|
|
132
|
+
return clampView({
|
|
133
|
+
yaw: view.yaw + dx * DRAG_TO_RADIANS,
|
|
134
|
+
// Dragging down tips the surface towards a top view, which is the direction the gesture
|
|
135
|
+
// suggests: the far edge comes up to meet the pointer.
|
|
136
|
+
pitch: view.pitch + dy * DRAG_TO_RADIANS,
|
|
137
|
+
zoom: view.zoom,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// The view a zoom of this factor leaves behind. A wheel notch and a pinch both arrive here.
|
|
142
|
+
export function zoomView(view: SurfaceView, factor: number): SurfaceView {
|
|
143
|
+
return clampView({ yaw: view.yaw, pitch: view.pitch, zoom: view.zoom * factor });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// Where one point of the unit cube lands on screen.
|
|
147
|
+
///
|
|
148
|
+
/// `nx` and `ny` run -1..1 across the floor, `nz` runs 0..1 upward. Exported because the widget
|
|
149
|
+
/// draws the floor frame from the same projection the quads use, and two projections that drift
|
|
150
|
+
/// apart would put the frame under a surface that has moved.
|
|
151
|
+
export function project(
|
|
152
|
+
nx: number, ny: number, nz: number, view: SurfaceView, box: SurfaceBox,
|
|
153
|
+
): { x: number; y: number; depth: number } {
|
|
154
|
+
const cy = Math.cos(view.yaw);
|
|
155
|
+
const sy = Math.sin(view.yaw);
|
|
156
|
+
const rx = nx * cy - ny * sy;
|
|
157
|
+
const ry = nx * sy + ny * cy;
|
|
158
|
+
|
|
159
|
+
const cp = Math.cos(view.pitch);
|
|
160
|
+
const sp = Math.sin(view.pitch);
|
|
161
|
+
|
|
162
|
+
// The scale that fits the rotated floor at any yaw: the diagonal is what has to fit, not the
|
|
163
|
+
// side, or the surface would grow and shrink as it turns.
|
|
164
|
+
const span = Math.min(box.width, box.height) / 2;
|
|
165
|
+
const unit = (span * view.zoom) / Math.SQRT2;
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
x: box.width / 2 + rx * unit,
|
|
169
|
+
// Screen y grows downward, so height subtracts.
|
|
170
|
+
y: box.height / 2 + (ry * sp - nz * cp) * unit,
|
|
171
|
+
// Larger is nearer the viewer.
|
|
172
|
+
depth: ry * cp + nz * sp,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Lay the surface out, or null when there is nothing to draw.
|
|
177
|
+
///
|
|
178
|
+
/// A quad needs all four of its corners, so a pair with no run leaves a hole rather than being
|
|
179
|
+
/// interpolated across - the same rule the flat map draws a gap by, for the same reason: an
|
|
180
|
+
/// absent result is not a result of zero.
|
|
181
|
+
export function surfaceLayout(input: SurfaceInput): SurfaceLayout | null {
|
|
182
|
+
const buckets = foldCells(input.cells);
|
|
183
|
+
if (buckets.length === 0) return null;
|
|
184
|
+
|
|
185
|
+
const xValues = axisOrder(buckets.map(b => b.x));
|
|
186
|
+
const yValues = axisOrder(buckets.map(b => b.y));
|
|
187
|
+
if (xValues.length < 2 || yValues.length < 2) return null;
|
|
188
|
+
|
|
189
|
+
const scale = heatScale(buckets);
|
|
190
|
+
const at = new Map<string, HeatBucket>();
|
|
191
|
+
for (const bucket of buckets) at.set(`${bucket.x} ${bucket.y}`, bucket);
|
|
192
|
+
|
|
193
|
+
const box: SurfaceBox = { width: input.width, height: input.height };
|
|
194
|
+
const view = clampView(input.view);
|
|
195
|
+
|
|
196
|
+
// Every grid point, projected once. A corner is shared by up to four quads, and projecting it
|
|
197
|
+
// per quad would cost four times as much and let rounding split one point into four.
|
|
198
|
+
const corners: { x: number; y: number; depth: number; tint: number }[][] = [];
|
|
199
|
+
const vertices: SurfaceVertex[] = [];
|
|
200
|
+
for (let ix = 0; ix < xValues.length; ix++) {
|
|
201
|
+
const column: { x: number; y: number; depth: number; tint: number }[] = [];
|
|
202
|
+
for (let iy = 0; iy < yValues.length; iy++) {
|
|
203
|
+
const bucket = at.get(`${xValues[ix]} ${yValues[iy]}`);
|
|
204
|
+
const tint = bucket === undefined ? 0 : tintOf(bucket.value, scale, input.betterWhen);
|
|
205
|
+
const nx = axisPosition(ix, xValues.length);
|
|
206
|
+
const ny = axisPosition(iy, yValues.length);
|
|
207
|
+
// Colour is signed around the scale's anchor and height is not: a run below the
|
|
208
|
+
// anchor is one the flat map paints red, but on a surface it still stands on the
|
|
209
|
+
// floor. So the elevation is the tint folded into 0..1, which puts the anchor half
|
|
210
|
+
// way up and makes the floor mean something rather than being the worst run's shelf.
|
|
211
|
+
const point = project(nx, ny, (tint + 1) / 2, view, box);
|
|
212
|
+
column.push({ ...point, tint });
|
|
213
|
+
// A pair nobody ran has no value to report, so it is not offered to the pointer.
|
|
214
|
+
if (bucket !== undefined) {
|
|
215
|
+
vertices.push({
|
|
216
|
+
at: [point.x, point.y],
|
|
217
|
+
x: xValues[ix],
|
|
218
|
+
y: yValues[iy],
|
|
219
|
+
value: bucket.value,
|
|
220
|
+
depth: point.depth,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
corners.push(column);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const quads: SurfaceQuad[] = [];
|
|
228
|
+
for (let ix = 0; ix + 1 < xValues.length; ix++) {
|
|
229
|
+
for (let iy = 0; iy + 1 < yValues.length; iy++) {
|
|
230
|
+
const near = at.get(`${xValues[ix]} ${yValues[iy]}`);
|
|
231
|
+
const holes = [
|
|
232
|
+
near,
|
|
233
|
+
at.get(`${xValues[ix + 1]} ${yValues[iy]}`),
|
|
234
|
+
at.get(`${xValues[ix + 1]} ${yValues[iy + 1]}`),
|
|
235
|
+
at.get(`${xValues[ix]} ${yValues[iy + 1]}`),
|
|
236
|
+
];
|
|
237
|
+
if (near === undefined || holes.some(b => b === undefined)) continue;
|
|
238
|
+
|
|
239
|
+
const a = corners[ix][iy];
|
|
240
|
+
const b = corners[ix + 1][iy];
|
|
241
|
+
const c = corners[ix + 1][iy + 1];
|
|
242
|
+
const d = corners[ix][iy + 1];
|
|
243
|
+
|
|
244
|
+
quads.push({
|
|
245
|
+
points: [[a.x, a.y], [b.x, b.y], [c.x, c.y], [d.x, d.y]],
|
|
246
|
+
// The mean of the corners, so a face slopes in colour the way it slopes in height
|
|
247
|
+
// rather than taking the tint of whichever corner happened to be first.
|
|
248
|
+
tint: (a.tint + b.tint + c.tint + d.tint) / 4,
|
|
249
|
+
depth: (a.depth + b.depth + c.depth + d.depth) / 4,
|
|
250
|
+
bucket: near,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Painter's algorithm: far first, so a near ridge covers what is behind it. Quads on a grid
|
|
256
|
+
// never interpenetrate, so sorting by mean depth is exact here rather than an approximation.
|
|
257
|
+
quads.sort((p, q) => p.depth - q.depth);
|
|
258
|
+
|
|
259
|
+
const floor = (nx: number, ny: number) => {
|
|
260
|
+
const p = project(nx, ny, 0, view, box);
|
|
261
|
+
return [p.x, p.y] as [number, number];
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Which way is out. An edge's ticks are pushed away from the floor's middle, so they stay
|
|
265
|
+
// outside the surface however far it has been turned - the direction is the turned edge's own
|
|
266
|
+
// rather than a fixed screen offset that would swing onto the landscape at some angles.
|
|
267
|
+
const outward = (from: [number, number], to: [number, number]): [number, number] => {
|
|
268
|
+
const centre = floor(0, 0);
|
|
269
|
+
const midX = (from[0] + to[0]) / 2;
|
|
270
|
+
const midY = (from[1] + to[1]) / 2;
|
|
271
|
+
const dx = midX - centre[0];
|
|
272
|
+
const dy = midY - centre[1];
|
|
273
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
274
|
+
return [dx / length, dy / length];
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// A square floor has two edges per axis, and the surface stands between them. Labels go on
|
|
278
|
+
// whichever is nearer the viewer: the far one is behind the landscape, where a number is drawn
|
|
279
|
+
// over the mesh it is supposed to be measuring. Which is nearer changes as the view turns, so
|
|
280
|
+
// it is decided per render rather than fixed.
|
|
281
|
+
const depthAt = (nx: number, ny: number) => project(nx, ny, 0, view, box).depth;
|
|
282
|
+
const xSide = depthAt(0, -1) >= depthAt(0, 1) ? -1 : 1;
|
|
283
|
+
const ySide = depthAt(-1, 0) >= depthAt(1, 0) ? -1 : 1;
|
|
284
|
+
|
|
285
|
+
const xEdge: [[number, number], [number, number]] = [floor(-1, xSide), floor(1, xSide)];
|
|
286
|
+
const yEdge: [[number, number], [number, number]] = [floor(ySide, -1), floor(ySide, 1)];
|
|
287
|
+
const xAway = outward(xEdge[0], xEdge[1]);
|
|
288
|
+
const yAway = outward(yEdge[0], yEdge[1]);
|
|
289
|
+
|
|
290
|
+
// The vertical edge rises from the corner OPPOSITE the two labelled ones. The corner they meet
|
|
291
|
+
// at is the nearest point of the floor, and the landscape stands directly over it - an axis
|
|
292
|
+
// there is drawn through the middle of the picture with its numbers over the mesh. The far
|
|
293
|
+
// corner is behind the surface, where a scale belongs.
|
|
294
|
+
const zBottom = project(-ySide, -xSide, 0, view, box);
|
|
295
|
+
const zTop = project(-ySide, -xSide, 1, view, box);
|
|
296
|
+
const zAway = outward([zBottom.x, zBottom.y], [zTop.x, zTop.y]);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
quads,
|
|
300
|
+
vertices,
|
|
301
|
+
axes: [
|
|
302
|
+
{
|
|
303
|
+
from: xEdge[0],
|
|
304
|
+
to: xEdge[1],
|
|
305
|
+
axis: 'x',
|
|
306
|
+
ticks: axisTicks(xValues, i => floor(axisPosition(i, xValues.length), xSide), xAway),
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
from: yEdge[0],
|
|
310
|
+
to: yEdge[1],
|
|
311
|
+
axis: 'y',
|
|
312
|
+
ticks: axisTicks(yValues, i => floor(ySide, axisPosition(i, yValues.length)), yAway),
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
from: [zBottom.x, zBottom.y],
|
|
316
|
+
to: [zTop.x, zTop.y],
|
|
317
|
+
axis: 'z',
|
|
318
|
+
// Five marks up the height, which is what the colour scale runs over: the floor is
|
|
319
|
+
// the worst run and the top the best. Worded by the caller, which knows the metric.
|
|
320
|
+
ticks: heightTicks(view, box, zAway, -ySide, -xSide),
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
xValues,
|
|
324
|
+
yValues,
|
|
325
|
+
scale,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/// The height a tick sits at, 0..1, for each mark on the vertical axis. Exported so a caller can
|
|
330
|
+
/// word them: the layout knows where they land, not what the metric is called.
|
|
331
|
+
export const SURFACE_HEIGHT_TICKS: readonly number[] = [0, 0.25, 0.5, 0.75, 1];
|
|
332
|
+
|
|
333
|
+
/// At most this many marks on a parameter axis. A sweep of forty settings would otherwise write
|
|
334
|
+
/// forty labels into the width of a chart and leave a smear.
|
|
335
|
+
const MAX_AXIS_TICKS = 8;
|
|
336
|
+
|
|
337
|
+
function axisTicks(
|
|
338
|
+
values: readonly string[],
|
|
339
|
+
at: (index: number) => [number, number],
|
|
340
|
+
away: [number, number],
|
|
341
|
+
): SurfaceTick[] {
|
|
342
|
+
if (values.length === 0) return [];
|
|
343
|
+
|
|
344
|
+
// Every value while they fit, then every second, every third... so the first and the last are
|
|
345
|
+
// always marked and the spacing stays even between them.
|
|
346
|
+
const step = Math.max(1, Math.ceil(values.length / MAX_AXIS_TICKS));
|
|
347
|
+
const ticks: SurfaceTick[] = [];
|
|
348
|
+
for (let i = 0; i < values.length; i += step) ticks.push({ at: at(i), label: values[i], away });
|
|
349
|
+
|
|
350
|
+
const last = values.length - 1;
|
|
351
|
+
if (last > 0 && (last % step) !== 0)
|
|
352
|
+
ticks.push({ at: at(last), label: values[last], away });
|
|
353
|
+
return ticks;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function heightTicks(
|
|
357
|
+
view: SurfaceView,
|
|
358
|
+
box: SurfaceBox,
|
|
359
|
+
away: [number, number],
|
|
360
|
+
nx: number,
|
|
361
|
+
ny: number,
|
|
362
|
+
): SurfaceTick[] {
|
|
363
|
+
return SURFACE_HEIGHT_TICKS.map(height => {
|
|
364
|
+
const point = project(nx, ny, height, view, box);
|
|
365
|
+
// The height itself as the label, for a caller that does not replace it. A caller that
|
|
366
|
+
// knows the metric words it as money or as a ratio.
|
|
367
|
+
return { at: [point.x, point.y] as [number, number], label: String(height), away };
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// The measured point nearest a screen position, or null when none is within `reach` pixels.
|
|
372
|
+
///
|
|
373
|
+
/// Ties break towards the viewer: two vertices over the same pixel are a near ridge in front of a
|
|
374
|
+
/// far one, and the near one is what a reader is pointing at.
|
|
375
|
+
export function nearestVertex(
|
|
376
|
+
vertices: readonly SurfaceVertex[],
|
|
377
|
+
x: number,
|
|
378
|
+
y: number,
|
|
379
|
+
reach: number,
|
|
380
|
+
): SurfaceVertex | null {
|
|
381
|
+
let best: SurfaceVertex | null = null;
|
|
382
|
+
let bestDistance = reach;
|
|
383
|
+
|
|
384
|
+
for (const vertex of vertices) {
|
|
385
|
+
const distance = Math.hypot(vertex.at[0] - x, vertex.at[1] - y);
|
|
386
|
+
if (distance > bestDistance) continue;
|
|
387
|
+
if (distance === bestDistance && best !== null && vertex.depth <= best.depth) continue;
|
|
388
|
+
best = vertex;
|
|
389
|
+
bestDistance = distance;
|
|
390
|
+
}
|
|
391
|
+
return best;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/// The distinct values of one axis, in the order the grid steps through them.
|
|
395
|
+
///
|
|
396
|
+
/// Numbers sort as numbers and everything else as text: a sweep of 5, 8, 12, 40 is a sequence,
|
|
397
|
+
/// and sorting it as text would put 12 before 5 and make the surface a lie about which way the
|
|
398
|
+
/// parameter runs.
|
|
399
|
+
function axisOrder(values: readonly string[]): string[] {
|
|
400
|
+
const distinct = [...new Set(values)];
|
|
401
|
+
const numeric = distinct.every(v => v.trim() !== '' && isFinite(Number(v)));
|
|
402
|
+
return numeric
|
|
403
|
+
? distinct.sort((a, b) => Number(a) - Number(b))
|
|
404
|
+
: distinct.sort((a, b) => a.localeCompare(b));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/// Where the nth of `count` steps sits across the floor, -1..1.
|
|
408
|
+
function axisPosition(index: number, count: number): number {
|
|
409
|
+
return count < 2 ? 0 : (index / (count - 1)) * 2 - 1;
|
|
410
|
+
}
|