multi-gauge 0.1.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/LICENSE +21 -0
- package/README.md +124 -0
- package/docs/multigauge-demo.png +0 -0
- package/package.json +29 -0
- package/src/MultiGauge.js +358 -0
- package/src/assets/fonts/InterVariable.woff2 +0 -0
- package/src/assets/fonts/LICENSE.txt +92 -0
- package/src/errors.js +7 -0
- package/src/gpu/GpuRuntime.js +153 -0
- package/src/gpu/IconCache.js +69 -0
- package/src/gpu/Renderer.js +370 -0
- package/src/gpu/SceneBuilder.js +736 -0
- package/src/gpu/TextAtlas.js +304 -0
- package/src/gpu/shaders.js +212 -0
- package/src/index.js +2 -0
- package/src/interaction/GridEditor.js +446 -0
- package/src/layout/CellLayout.js +167 -0
- package/src/layout/GridLayout.js +405 -0
- package/src/layout/PanelLayout.js +70 -0
- package/src/model/GaugeModel.js +196 -0
- package/src/theme.js +41 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { localRect } from '../layout/PanelLayout.js';
|
|
2
|
+
|
|
3
|
+
const HYSTERESIS = 0.62;
|
|
4
|
+
const EDGE_HIT_SIZE = 18;
|
|
5
|
+
|
|
6
|
+
function setRect(element, rect) {
|
|
7
|
+
element.style.left = `${rect.x}px`;
|
|
8
|
+
element.style.top = `${rect.y}px`;
|
|
9
|
+
element.style.width = `${rect.width}px`;
|
|
10
|
+
element.style.height = `${rect.height}px`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Transactional DOM interaction layer. GridLayout remains the source of truth. */
|
|
14
|
+
export class GridEditor {
|
|
15
|
+
#canvas;
|
|
16
|
+
#overlay;
|
|
17
|
+
#placeholder;
|
|
18
|
+
#layout;
|
|
19
|
+
#actions;
|
|
20
|
+
#nodes = new Map();
|
|
21
|
+
#rectangles = new Map();
|
|
22
|
+
#drag;
|
|
23
|
+
#frame;
|
|
24
|
+
#parentPosition;
|
|
25
|
+
#panelLayout;
|
|
26
|
+
|
|
27
|
+
constructor(canvas, layout, actions) {
|
|
28
|
+
this.#canvas = canvas;
|
|
29
|
+
this.#layout = layout;
|
|
30
|
+
this.#actions = actions;
|
|
31
|
+
const parent = canvas.parentElement;
|
|
32
|
+
if (!parent) {
|
|
33
|
+
throw new Error('Grid editing requires the canvas to have a parent element.');
|
|
34
|
+
}
|
|
35
|
+
this.#parentPosition = parent.style.position;
|
|
36
|
+
if (getComputedStyle(parent).position === 'static') {
|
|
37
|
+
parent.style.position = 'relative';
|
|
38
|
+
}
|
|
39
|
+
this.#overlay = document.createElement('div');
|
|
40
|
+
this.#overlay.dataset.multigaugeEditor = '';
|
|
41
|
+
Object.assign(this.#overlay.style, {
|
|
42
|
+
position: 'absolute',
|
|
43
|
+
zIndex: '10',
|
|
44
|
+
overflow: 'hidden',
|
|
45
|
+
touchAction: 'none'
|
|
46
|
+
});
|
|
47
|
+
this.#placeholder = document.createElement('div');
|
|
48
|
+
this.#placeholder.dataset.gridPlaceholder = '';
|
|
49
|
+
Object.assign(this.#placeholder.style, {
|
|
50
|
+
position: 'absolute',
|
|
51
|
+
display: 'none',
|
|
52
|
+
boxSizing: 'border-box',
|
|
53
|
+
border: '1px solid #00eaff',
|
|
54
|
+
borderRadius: '7px',
|
|
55
|
+
background: 'rgba(0, 234, 255, 0.10)',
|
|
56
|
+
pointerEvents: 'none',
|
|
57
|
+
transition: 'transform 90ms ease, width 90ms ease, height 90ms ease'
|
|
58
|
+
});
|
|
59
|
+
this.#overlay.append(this.#placeholder);
|
|
60
|
+
parent.append(this.#overlay);
|
|
61
|
+
this.#overlay.addEventListener('pointerdown', this.#onPointerDown);
|
|
62
|
+
this.#overlay.addEventListener('pointermove', this.#onPointerMove);
|
|
63
|
+
this.#overlay.addEventListener('pointerup', this.#onPointerUp);
|
|
64
|
+
this.#overlay.addEventListener('pointercancel', this.#onPointerCancel);
|
|
65
|
+
window.addEventListener('keydown', this.#onKeyDown);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Synchronize committed geometry without rebuilding existing DOM nodes. */
|
|
69
|
+
refresh(rectangles, panelLayout) {
|
|
70
|
+
this.#panelLayout = panelLayout;
|
|
71
|
+
const gridRect = panelLayout.gridRect;
|
|
72
|
+
this.#rectangles = new Map([...rectangles]
|
|
73
|
+
.map(([id, rect]) => [id, localRect(rect, gridRect)]));
|
|
74
|
+
const style = getComputedStyle(this.#canvas);
|
|
75
|
+
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0;
|
|
76
|
+
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
|
|
77
|
+
Object.assign(this.#overlay.style, {
|
|
78
|
+
left: `${this.#canvas.offsetLeft + this.#canvas.clientLeft + paddingLeft + gridRect.x}px`,
|
|
79
|
+
top: `${this.#canvas.offsetTop + this.#canvas.clientTop + paddingTop + gridRect.y}px`,
|
|
80
|
+
width: `${gridRect.width}px`,
|
|
81
|
+
height: `${gridRect.height}px`
|
|
82
|
+
});
|
|
83
|
+
for (const [id, node] of this.#nodes) {
|
|
84
|
+
if (!rectangles.has(id)) {
|
|
85
|
+
node.remove();
|
|
86
|
+
this.#nodes.delete(id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const [id, rect] of this.#rectangles) {
|
|
90
|
+
let item = this.#nodes.get(id);
|
|
91
|
+
if (!item) {
|
|
92
|
+
item = this.#createItem(id);
|
|
93
|
+
this.#nodes.set(id, item);
|
|
94
|
+
this.#overlay.append(item);
|
|
95
|
+
}
|
|
96
|
+
setRect(item, rect);
|
|
97
|
+
if (!this.#drag) {
|
|
98
|
+
item.style.transform = 'translate3d(0, 0, 0)';
|
|
99
|
+
}
|
|
100
|
+
item.style.borderColor = this.#drag?.id === id ? '#00eaff' : 'transparent';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
destroy() {
|
|
105
|
+
const parent = this.#canvas.parentElement;
|
|
106
|
+
this.#cancelDrag();
|
|
107
|
+
this.#overlay.removeEventListener('pointerdown', this.#onPointerDown);
|
|
108
|
+
this.#overlay.removeEventListener('pointermove', this.#onPointerMove);
|
|
109
|
+
this.#overlay.removeEventListener('pointerup', this.#onPointerUp);
|
|
110
|
+
this.#overlay.removeEventListener('pointercancel', this.#onPointerCancel);
|
|
111
|
+
window.removeEventListener('keydown', this.#onKeyDown);
|
|
112
|
+
this.#overlay.remove();
|
|
113
|
+
if (parent) {
|
|
114
|
+
parent.style.position = this.#parentPosition;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#createItem(id) {
|
|
119
|
+
const item = document.createElement('div');
|
|
120
|
+
item.dataset.gaugeId = id;
|
|
121
|
+
Object.assign(item.style, {
|
|
122
|
+
position: 'absolute',
|
|
123
|
+
boxSizing: 'border-box',
|
|
124
|
+
border: '1px solid transparent',
|
|
125
|
+
borderRadius: '7px',
|
|
126
|
+
cursor: 'grab',
|
|
127
|
+
background: 'transparent',
|
|
128
|
+
willChange: 'transform',
|
|
129
|
+
contain: 'layout style paint'
|
|
130
|
+
});
|
|
131
|
+
return item;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#onPointerDown = (event) => {
|
|
135
|
+
if (this.#drag || event.button !== 0) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const item = event.target.closest('[data-gauge-id]');
|
|
139
|
+
if (!item) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const id = item.dataset.gaugeId;
|
|
143
|
+
const resizeEdges = this.#resizeEdges(item, event);
|
|
144
|
+
const resize = Boolean(resizeEdges);
|
|
145
|
+
const initial = this.#layout.get(id);
|
|
146
|
+
const rect = this.#rectangles.get(id);
|
|
147
|
+
if (!initial || !rect) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const point = this.#pointerLocal(event);
|
|
151
|
+
this.#drag = {
|
|
152
|
+
id,
|
|
153
|
+
item,
|
|
154
|
+
pointerId: event.pointerId,
|
|
155
|
+
startX: point.x,
|
|
156
|
+
startY: point.y,
|
|
157
|
+
x: point.x,
|
|
158
|
+
y: point.y,
|
|
159
|
+
dx: 0,
|
|
160
|
+
dy: 0,
|
|
161
|
+
initial,
|
|
162
|
+
initialRect: { ...rect },
|
|
163
|
+
gridRow: initial.row,
|
|
164
|
+
gridCol: initial.col,
|
|
165
|
+
rowSpan: initial.rowSpan,
|
|
166
|
+
colSpan: initial.colSpan,
|
|
167
|
+
resize,
|
|
168
|
+
resizeX: resizeEdges?.x ?? 0,
|
|
169
|
+
resizeY: resizeEdges?.y ?? 0,
|
|
170
|
+
valid: true,
|
|
171
|
+
previewed: false,
|
|
172
|
+
session: this.#layout.beginDrag(id, resize ? 'resize' : 'move')
|
|
173
|
+
};
|
|
174
|
+
item.setPointerCapture(event.pointerId);
|
|
175
|
+
item.style.cursor = resize ? this.#resizeCursor(resizeEdges) : 'grabbing';
|
|
176
|
+
item.style.zIndex = '3';
|
|
177
|
+
item.style.borderStyle = 'solid';
|
|
178
|
+
item.style.borderColor = '#00eaff';
|
|
179
|
+
item.style.background = 'rgba(0, 234, 255, 0.08)';
|
|
180
|
+
this.#placeholder.style.display = 'block';
|
|
181
|
+
setRect(this.#placeholder, rect);
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
#onPointerMove = (event) => {
|
|
186
|
+
if (!this.#drag) {
|
|
187
|
+
const item = event.target.closest?.('[data-gauge-id]');
|
|
188
|
+
if (item) {
|
|
189
|
+
item.style.cursor = this.#resizeCursor(this.#resizeEdges(item, event));
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (event.pointerId !== this.#drag.pointerId) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const point = this.#pointerLocal(event);
|
|
197
|
+
this.#drag.x = point.x;
|
|
198
|
+
this.#drag.y = point.y;
|
|
199
|
+
this.#drag.dx = point.x - this.#drag.startX;
|
|
200
|
+
this.#drag.dy = point.y - this.#drag.startY;
|
|
201
|
+
if (this.#frame === undefined) {
|
|
202
|
+
this.#frame = requestAnimationFrame(this.#applyPointerTransform);
|
|
203
|
+
}
|
|
204
|
+
this.#updateGridPreview();
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
#applyPointerTransform = () => {
|
|
209
|
+
this.#frame = undefined;
|
|
210
|
+
if (!this.#drag) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (this.#drag.resize) {
|
|
214
|
+
const minWidth = Math.min(24, this.#drag.initialRect.width);
|
|
215
|
+
const minHeight = Math.min(24, this.#drag.initialRect.height);
|
|
216
|
+
const widthDelta = this.#drag.dx * this.#drag.resizeX;
|
|
217
|
+
const heightDelta = this.#drag.dy * this.#drag.resizeY;
|
|
218
|
+
const width = Math.max(minWidth, this.#drag.initialRect.width + widthDelta);
|
|
219
|
+
const height = Math.max(minHeight, this.#drag.initialRect.height + heightDelta);
|
|
220
|
+
const offsetX = this.#drag.resizeX < 0
|
|
221
|
+
? this.#drag.initialRect.width - width
|
|
222
|
+
: 0;
|
|
223
|
+
const offsetY = this.#drag.resizeY < 0
|
|
224
|
+
? this.#drag.initialRect.height - height
|
|
225
|
+
: 0;
|
|
226
|
+
const scaleX = width / this.#drag.initialRect.width;
|
|
227
|
+
const scaleY = height / this.#drag.initialRect.height;
|
|
228
|
+
this.#drag.item.style.transformOrigin = '0 0';
|
|
229
|
+
this.#drag.item.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${scaleX}, ${scaleY})`;
|
|
230
|
+
} else {
|
|
231
|
+
this.#drag.item.style.transform = `translate3d(${this.#drag.dx}px, ${this.#drag.dy}px, 0)`;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
#updateGridPreview() {
|
|
236
|
+
const drag = this.#drag;
|
|
237
|
+
const { rows, columns, gap } = this.#layout.config;
|
|
238
|
+
const gridRect = this.#panelLayout.gridRect;
|
|
239
|
+
const cellWidth = (gridRect.width - gap * (columns - 1)) / columns;
|
|
240
|
+
const cellHeight = (gridRect.height - gap * (rows - 1)) / rows;
|
|
241
|
+
const columnPitch = cellWidth + gap;
|
|
242
|
+
const rowPitch = cellHeight + gap;
|
|
243
|
+
let nextRow = drag.gridRow;
|
|
244
|
+
let nextCol = drag.gridCol;
|
|
245
|
+
let nextRowSpan = drag.rowSpan;
|
|
246
|
+
let nextColSpan = drag.colSpan;
|
|
247
|
+
|
|
248
|
+
if (drag.resize) {
|
|
249
|
+
if (drag.resizeX < 0) {
|
|
250
|
+
nextCol = this.#quantize(drag.initial.col + drag.dx / columnPitch,
|
|
251
|
+
drag.gridCol, 0, drag.initial.col + drag.initial.colSpan - 1);
|
|
252
|
+
nextColSpan = drag.initial.colSpan + drag.initial.col - nextCol;
|
|
253
|
+
} else if (drag.resizeX > 0) {
|
|
254
|
+
nextColSpan = this.#quantize(drag.initial.colSpan + drag.dx / columnPitch,
|
|
255
|
+
drag.colSpan, 1, columns - drag.initial.col);
|
|
256
|
+
}
|
|
257
|
+
if (drag.resizeY < 0) {
|
|
258
|
+
nextRow = this.#quantize(drag.initial.row + drag.dy / rowPitch,
|
|
259
|
+
drag.gridRow, 0, drag.initial.row + drag.initial.rowSpan - 1);
|
|
260
|
+
nextRowSpan = drag.initial.rowSpan + drag.initial.row - nextRow;
|
|
261
|
+
} else if (drag.resizeY > 0) {
|
|
262
|
+
nextRowSpan = this.#quantize(drag.initial.rowSpan + drag.dy / rowPitch,
|
|
263
|
+
drag.rowSpan, 1, rows - drag.initial.row);
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
nextCol = this.#quantize(drag.initial.col + drag.dx / columnPitch,
|
|
267
|
+
drag.gridCol, 0, columns - drag.initial.colSpan);
|
|
268
|
+
nextRow = this.#quantize(drag.initial.row + drag.dy / rowPitch,
|
|
269
|
+
drag.gridRow, 0, rows - drag.initial.rowSpan);
|
|
270
|
+
}
|
|
271
|
+
if (nextRow === drag.gridRow && nextCol === drag.gridCol
|
|
272
|
+
&& nextRowSpan === drag.rowSpan && nextColSpan === drag.colSpan) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
drag.gridRow = nextRow;
|
|
276
|
+
drag.gridCol = nextCol;
|
|
277
|
+
drag.rowSpan = nextRowSpan;
|
|
278
|
+
drag.colSpan = nextColSpan;
|
|
279
|
+
drag.previewed = true;
|
|
280
|
+
const preview = drag.resize
|
|
281
|
+
? drag.session.preview({
|
|
282
|
+
row: nextRow,
|
|
283
|
+
col: nextCol,
|
|
284
|
+
rowSpan: nextRowSpan,
|
|
285
|
+
colSpan: nextColSpan
|
|
286
|
+
})
|
|
287
|
+
: drag.session.preview({ row: nextRow, col: nextCol });
|
|
288
|
+
drag.valid = preview.valid;
|
|
289
|
+
this.#showPreview(preview.entries, preview.valid, {
|
|
290
|
+
...drag.initial,
|
|
291
|
+
row: nextRow,
|
|
292
|
+
col: nextCol,
|
|
293
|
+
rowSpan: nextRowSpan,
|
|
294
|
+
colSpan: nextColSpan
|
|
295
|
+
});
|
|
296
|
+
this.#actions.preview(preview.entries);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#showPreview(entries, valid, candidate) {
|
|
300
|
+
const gridRect = this.#panelLayout.gridRect;
|
|
301
|
+
const previewRects = new Map([...this.#layout.rectangles(gridRect, entries)]
|
|
302
|
+
.map(([id, rect]) => [id, localRect(rect, gridRect)]));
|
|
303
|
+
for (const [id, node] of this.#nodes) {
|
|
304
|
+
if (id === this.#drag.id) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const committed = this.#rectangles.get(id);
|
|
308
|
+
const preview = previewRects.get(id);
|
|
309
|
+
if (committed && preview) {
|
|
310
|
+
node.style.transform = `translate3d(${preview.x - committed.x}px, ${preview.y - committed.y}px, 0)`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const placeholderRect = valid
|
|
314
|
+
? previewRects.get(this.#drag.id)
|
|
315
|
+
: localRect(this.#layout.rectangles(
|
|
316
|
+
gridRect,
|
|
317
|
+
new Map([[this.#drag.id, candidate]])
|
|
318
|
+
).get(this.#drag.id), gridRect);
|
|
319
|
+
setRect(this.#placeholder, placeholderRect);
|
|
320
|
+
this.#placeholder.style.borderColor = valid ? '#00eaff' : '#ff4d6d';
|
|
321
|
+
this.#placeholder.style.background = valid
|
|
322
|
+
? 'rgba(0, 234, 255, 0.10)'
|
|
323
|
+
: 'rgba(255, 77, 109, 0.10)';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#onPointerUp = (event) => {
|
|
327
|
+
if (!this.#drag || event.pointerId !== this.#drag.pointerId) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!this.#drag.previewed) {
|
|
331
|
+
this.#drag.session.cancel();
|
|
332
|
+
this.#finishDrag(null);
|
|
333
|
+
event.preventDefault();
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const committed = this.#drag.valid && this.#drag.session.commit();
|
|
337
|
+
if (!committed) {
|
|
338
|
+
this.#drag.session.cancel();
|
|
339
|
+
}
|
|
340
|
+
this.#finishDrag(committed);
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
#onPointerCancel = (event) => {
|
|
345
|
+
if (this.#drag && event.pointerId === this.#drag.pointerId) {
|
|
346
|
+
this.#cancelDrag();
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
#onKeyDown = (event) => {
|
|
351
|
+
if (event.key === 'Escape' && this.#drag) {
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
this.#cancelDrag();
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
#cancelDrag() {
|
|
358
|
+
if (!this.#drag) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const previewed = this.#drag.previewed;
|
|
362
|
+
this.#drag.session.cancel();
|
|
363
|
+
this.#finishDrag(previewed ? false : null);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#finishDrag(committed) {
|
|
367
|
+
const drag = this.#drag;
|
|
368
|
+
if (!drag) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (this.#frame !== undefined) {
|
|
372
|
+
cancelAnimationFrame(this.#frame);
|
|
373
|
+
this.#frame = undefined;
|
|
374
|
+
}
|
|
375
|
+
if (drag.item.hasPointerCapture?.(drag.pointerId)) {
|
|
376
|
+
drag.item.releasePointerCapture(drag.pointerId);
|
|
377
|
+
}
|
|
378
|
+
drag.item.style.transform = 'translate3d(0, 0, 0)';
|
|
379
|
+
drag.item.style.transformOrigin = '';
|
|
380
|
+
drag.item.style.cursor = 'grab';
|
|
381
|
+
drag.item.style.zIndex = '';
|
|
382
|
+
drag.item.style.borderColor = 'transparent';
|
|
383
|
+
drag.item.style.background = 'transparent';
|
|
384
|
+
this.#placeholder.style.display = 'none';
|
|
385
|
+
this.#drag = null;
|
|
386
|
+
for (const node of this.#nodes.values()) {
|
|
387
|
+
node.style.transform = 'translate3d(0, 0, 0)';
|
|
388
|
+
}
|
|
389
|
+
if (committed === true) {
|
|
390
|
+
this.#actions.commit();
|
|
391
|
+
} else if (committed === false) {
|
|
392
|
+
this.#actions.cancel();
|
|
393
|
+
}
|
|
394
|
+
this.refresh(this.#layout.rectangles(this.#panelLayout.gridRect), this.#panelLayout);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#pointerLocal(event) {
|
|
398
|
+
const bounds = this.#overlay.getBoundingClientRect();
|
|
399
|
+
const gridRect = this.#panelLayout.gridRect;
|
|
400
|
+
return {
|
|
401
|
+
x: bounds.width > 0 ? (event.clientX - bounds.left) * gridRect.width / bounds.width : 0,
|
|
402
|
+
y: bounds.height > 0 ? (event.clientY - bounds.top) * gridRect.height / bounds.height : 0
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#resizeEdges(item, event) {
|
|
407
|
+
const bounds = item.getBoundingClientRect();
|
|
408
|
+
const hitX = Math.min(EDGE_HIT_SIZE, bounds.width * 0.24);
|
|
409
|
+
const hitY = Math.min(EDGE_HIT_SIZE, bounds.height * 0.24);
|
|
410
|
+
const left = event.clientX - bounds.left <= hitX;
|
|
411
|
+
const right = bounds.right - event.clientX <= hitX;
|
|
412
|
+
const top = event.clientY - bounds.top <= hitY;
|
|
413
|
+
const bottom = bounds.bottom - event.clientY <= hitY;
|
|
414
|
+
if (!(left || right || top || bottom)) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
x: left ? -1 : right ? 1 : 0,
|
|
419
|
+
y: top ? -1 : bottom ? 1 : 0
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#resizeCursor(edges) {
|
|
424
|
+
if (!edges) {
|
|
425
|
+
return 'grab';
|
|
426
|
+
}
|
|
427
|
+
if (edges.x === 0) {
|
|
428
|
+
return 'ns-resize';
|
|
429
|
+
}
|
|
430
|
+
if (edges.y === 0) {
|
|
431
|
+
return 'ew-resize';
|
|
432
|
+
}
|
|
433
|
+
return edges.x === edges.y ? 'nwse-resize' : 'nesw-resize';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#quantize(raw, current, min, max) {
|
|
437
|
+
let value = current;
|
|
438
|
+
while (value < max && raw > value + HYSTERESIS) {
|
|
439
|
+
value += 1;
|
|
440
|
+
}
|
|
441
|
+
while (value > min && raw < value - HYSTERESIS) {
|
|
442
|
+
value -= 1;
|
|
443
|
+
}
|
|
444
|
+
return value;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
function bucket(width, height) {
|
|
2
|
+
if (width < 180 || height < 150) {
|
|
3
|
+
return 'small';
|
|
4
|
+
}
|
|
5
|
+
if (width < 300 || height < 230) {
|
|
6
|
+
return 'medium';
|
|
7
|
+
}
|
|
8
|
+
return 'large';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fit one unwrapped line using a caller-provided real metrics function.
|
|
13
|
+
* Returns the chosen text and size without mutating or changing its case.
|
|
14
|
+
*/
|
|
15
|
+
export function fitText(text, maxWidth, options) {
|
|
16
|
+
const value = String(text ?? '');
|
|
17
|
+
const measure = options?.measure;
|
|
18
|
+
if (typeof measure !== 'function') {
|
|
19
|
+
throw new TypeError('fitText() requires a measure(text, size, font) function.');
|
|
20
|
+
}
|
|
21
|
+
const targetSize = Math.max(1, Number(options.targetSize) || 11);
|
|
22
|
+
const minSize = Math.min(targetSize, Math.max(1, Number(options.minSize) || 9));
|
|
23
|
+
const step = Math.max(0.25, Number(options.step) || 0.5);
|
|
24
|
+
const font = options.font ?? 'regular';
|
|
25
|
+
const ellipsis = options.ellipsis ?? '…';
|
|
26
|
+
const available = Math.max(0, Number(maxWidth) || 0);
|
|
27
|
+
|
|
28
|
+
for (let size = targetSize; size >= minSize; size -= step) {
|
|
29
|
+
const width = measure(value, size, font);
|
|
30
|
+
if (width <= available) {
|
|
31
|
+
return { text: value, size, width, truncated: false, font };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ellipsisWidth = measure(ellipsis, minSize, font);
|
|
36
|
+
if (ellipsisWidth > available) {
|
|
37
|
+
return { text: '', size: minSize, width: 0, truncated: value.length > 0, font };
|
|
38
|
+
}
|
|
39
|
+
let fitted = '';
|
|
40
|
+
for (const character of value) {
|
|
41
|
+
const candidate = `${fitted}${character}${ellipsis}`;
|
|
42
|
+
if (measure(candidate, minSize, font) > available) {
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
fitted += character;
|
|
46
|
+
}
|
|
47
|
+
const result = fitted.length < value.length ? `${fitted}${ellipsis}` : fitted;
|
|
48
|
+
return {
|
|
49
|
+
text: result,
|
|
50
|
+
size: minSize,
|
|
51
|
+
width: measure(result, minSize, font),
|
|
52
|
+
truncated: result !== value,
|
|
53
|
+
font
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Return the common meta/gauge/readout composition for one gauge cell. */
|
|
58
|
+
export function layoutCell({
|
|
59
|
+
width,
|
|
60
|
+
height,
|
|
61
|
+
gaugeType = 'arc',
|
|
62
|
+
orientation = 'horizontal',
|
|
63
|
+
hasInfo = false,
|
|
64
|
+
hasUnit = false,
|
|
65
|
+
hasMarkers = false,
|
|
66
|
+
hasMarkerLabels = false,
|
|
67
|
+
hasBandLabels = false
|
|
68
|
+
} = {}) {
|
|
69
|
+
const cellWidth = Math.max(1, Number(width) || 1);
|
|
70
|
+
const cellHeight = Math.max(1, Number(height) || 1);
|
|
71
|
+
const level = bucket(cellWidth, cellHeight);
|
|
72
|
+
const sizes = {
|
|
73
|
+
small: { padding: 8, label: 10, minLabel: 8.5, info: 9, value: 17, unit: 11, readout: 25 },
|
|
74
|
+
medium: { padding: 11, label: 11, minLabel: 9, info: 10, value: 23, unit: 12, readout: 32 },
|
|
75
|
+
large: { padding: 13, label: 12, minLabel: 9, info: 11, value: 29, unit: 13, readout: 39 }
|
|
76
|
+
}[level];
|
|
77
|
+
const showInfo = hasInfo && level !== 'small';
|
|
78
|
+
const showUnit = hasUnit && cellWidth >= 72 && cellHeight >= 82;
|
|
79
|
+
const labelLineHeight = sizes.label + 4;
|
|
80
|
+
const infoLineHeight = showInfo ? sizes.info + 3 : 0;
|
|
81
|
+
const metaHeight = labelLineHeight + (showInfo ? infoLineHeight + 1 : 0);
|
|
82
|
+
const innerWidth = Math.max(1, cellWidth - sizes.padding * 2);
|
|
83
|
+
const metaY = sizes.padding;
|
|
84
|
+
const gaugeY = metaY + metaHeight + 3;
|
|
85
|
+
const availableAfterMeta = Math.max(1, cellHeight - sizes.padding - gaugeY);
|
|
86
|
+
const valueSize = gaugeType === 'status' ? Math.min(sizes.value, 23) : sizes.value;
|
|
87
|
+
const centeredReadout = gaugeType === 'arc' || gaugeType === 'compass';
|
|
88
|
+
const sideReadout = gaugeType === 'linear' && orientation === 'vertical';
|
|
89
|
+
const footerReadoutHeight = Math.min(sizes.readout, Math.max(1, availableAfterMeta * 0.48));
|
|
90
|
+
const footerReadoutY = Math.max(gaugeY + 1, cellHeight - sizes.padding - footerReadoutHeight);
|
|
91
|
+
const gaugeHeight = centeredReadout || sideReadout
|
|
92
|
+
? availableAfterMeta
|
|
93
|
+
: Math.max(1, footerReadoutY - gaugeY - 3);
|
|
94
|
+
const stackedReadoutHeight = Math.min(
|
|
95
|
+
gaugeHeight * 0.62,
|
|
96
|
+
valueSize + (showUnit ? sizes.unit + 6 : 2)
|
|
97
|
+
);
|
|
98
|
+
const sideReadoutHeight = Math.min(
|
|
99
|
+
gaugeHeight * 0.5,
|
|
100
|
+
Math.max(valueSize, showUnit ? sizes.unit : 0) + 4
|
|
101
|
+
);
|
|
102
|
+
const readoutHeight = centeredReadout
|
|
103
|
+
? stackedReadoutHeight
|
|
104
|
+
: sideReadout ? sideReadoutHeight : footerReadoutHeight;
|
|
105
|
+
const readoutY = centeredReadout || sideReadout
|
|
106
|
+
? gaugeY + (gaugeHeight - readoutHeight) / 2
|
|
107
|
+
: footerReadoutY;
|
|
108
|
+
const sideGap = sideReadout ? 4 : 0;
|
|
109
|
+
const sideGaugeRatio = hasBandLabels ? 0.56 : 0.44;
|
|
110
|
+
const sideGaugeWidth = sideReadout
|
|
111
|
+
? Math.max(24, Math.min(innerWidth * sideGaugeRatio, innerWidth - sideGap - 1))
|
|
112
|
+
: innerWidth;
|
|
113
|
+
const readoutX = sideReadout ? sizes.padding + sideGaugeWidth + sideGap : sizes.padding;
|
|
114
|
+
const readoutWidth = sideReadout
|
|
115
|
+
? Math.max(1, innerWidth - sideGaugeWidth - sideGap)
|
|
116
|
+
: innerWidth;
|
|
117
|
+
|
|
118
|
+
const labelRect = { x: sizes.padding, y: metaY, width: innerWidth, height: labelLineHeight };
|
|
119
|
+
const infoRect = {
|
|
120
|
+
x: sizes.padding,
|
|
121
|
+
y: metaY + labelLineHeight + 1,
|
|
122
|
+
width: innerWidth,
|
|
123
|
+
height: infoLineHeight
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
level,
|
|
128
|
+
padding: sizes.padding,
|
|
129
|
+
metaRect: {
|
|
130
|
+
x: sizes.padding,
|
|
131
|
+
y: metaY,
|
|
132
|
+
width: innerWidth,
|
|
133
|
+
height: metaHeight
|
|
134
|
+
},
|
|
135
|
+
labelRect,
|
|
136
|
+
infoRect,
|
|
137
|
+
gaugeRect: {
|
|
138
|
+
x: sizes.padding,
|
|
139
|
+
y: gaugeY,
|
|
140
|
+
width: sideGaugeWidth,
|
|
141
|
+
height: gaugeHeight
|
|
142
|
+
},
|
|
143
|
+
readoutRect: {
|
|
144
|
+
x: readoutX,
|
|
145
|
+
y: readoutY,
|
|
146
|
+
width: readoutWidth,
|
|
147
|
+
height: readoutHeight
|
|
148
|
+
},
|
|
149
|
+
readoutMode: centeredReadout
|
|
150
|
+
? 'center-stacked'
|
|
151
|
+
: sideReadout ? 'side-inline' : 'footer-inline',
|
|
152
|
+
typography: {
|
|
153
|
+
label: { size: sizes.label, minSize: sizes.minLabel, font: 'label', alpha: 0.95 },
|
|
154
|
+
info: { size: sizes.info, minSize: 8.5, font: 'regular', alpha: 0.70 },
|
|
155
|
+
value: { size: valueSize, minSize: gaugeType === 'status' ? 12 : 15, font: 'value', alpha: 1 },
|
|
156
|
+
unit: { size: sizes.unit, minSize: 9, font: 'unit', alpha: 0.75 },
|
|
157
|
+
ticks: { alpha: 0.52 }
|
|
158
|
+
},
|
|
159
|
+
showInfo,
|
|
160
|
+
showUnit,
|
|
161
|
+
showImportantMarker: hasMarkers,
|
|
162
|
+
showTicks: level !== 'small',
|
|
163
|
+
showBandLabels: hasBandLabels && gaugeType !== 'status',
|
|
164
|
+
showMarkerLabels: hasMarkerLabels,
|
|
165
|
+
showCompassLabels: level !== 'small'
|
|
166
|
+
};
|
|
167
|
+
}
|