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.
@@ -0,0 +1,304 @@
1
+ const FONT_FAMILY = 'MultiGauge Inter';
2
+ const FONT_URL = new URL('../assets/fonts/InterVariable.woff2', import.meta.url);
3
+ const CHARACTERS = [...new Set(
4
+ Array.from({ length: 95 }, (_, index) => String.fromCharCode(index + 32)).join('')
5
+ + '°…µ·'
6
+ )].join('');
7
+
8
+ const STYLE_DEFINITIONS = Object.freeze([
9
+ { id: 'uiSmall', role: 'regular', logicalSize: 10, weight: 400 },
10
+ { id: 'uiMedium', role: 'regular', logicalSize: 11, weight: 400 },
11
+ { id: 'unitSmall', role: 'unit', logicalSize: 11, weight: 400 },
12
+ { id: 'unitMedium', role: 'unit', logicalSize: 12, weight: 400 },
13
+ { id: 'unitLarge', role: 'unit', logicalSize: 13, weight: 400 },
14
+ { id: 'labelSmall', role: 'label', logicalSize: 10, weight: 600 },
15
+ { id: 'labelMedium', role: 'label', logicalSize: 12, weight: 600 },
16
+ { id: 'valueSmall', role: 'value', logicalSize: 17, weight: 600, tabular: true },
17
+ { id: 'valueMedium', role: 'value', logicalSize: 23, weight: 500, tabular: true },
18
+ { id: 'valueLarge', role: 'value', logicalSize: 29, weight: 500, tabular: true },
19
+ { id: 'header', role: 'header', logicalSize: 16, weight: 600 }
20
+ ]);
21
+
22
+ function nextPowerOfTwo(value) {
23
+ let result = 1;
24
+ while (result < value) {
25
+ result *= 2;
26
+ }
27
+ return result;
28
+ }
29
+
30
+ function makeCanvas(width, height) {
31
+ return typeof OffscreenCanvas === 'function'
32
+ ? new OffscreenCanvas(width, height)
33
+ : Object.assign(document.createElement('canvas'), { width, height });
34
+ }
35
+
36
+ function fontString(style, pixelRatio) {
37
+ return `${style.weight} ${style.logicalSize * pixelRatio}px "${FONT_FAMILY}"`;
38
+ }
39
+
40
+ function finiteMetric(value, fallback = 0) {
41
+ return Number.isFinite(value) ? value : fallback;
42
+ }
43
+
44
+ /** A DPR-aware bitmap atlas with a small set of fixed Inter typography styles. */
45
+ export class TextAtlas {
46
+ static CHARACTERS = CHARACTERS;
47
+ static STYLES = STYLE_DEFINITIONS;
48
+
49
+ #device;
50
+ #pixelRatio;
51
+ #texture;
52
+ #view;
53
+ #sampler;
54
+ #styles = new Map();
55
+ #stylesByRole = new Map();
56
+ #styleCache = new Map();
57
+ #metricsCache = new Map();
58
+ #measureCache = new Map();
59
+ #fontFace;
60
+
61
+ constructor(device, pixelRatio = globalThis.devicePixelRatio || 1) {
62
+ this.#device = device;
63
+ this.#pixelRatio = Math.max(1, Number(pixelRatio) || 1);
64
+ }
65
+
66
+ get view() {
67
+ return this.#view;
68
+ }
69
+
70
+ get sampler() {
71
+ return this.#sampler;
72
+ }
73
+
74
+ get pixelRatio() {
75
+ return this.#pixelRatio;
76
+ }
77
+
78
+ style(role = 'regular', size = 10) {
79
+ const key = `${role}\0${size}`;
80
+ const cached = this.#styleCache.get(key);
81
+ if (cached) {
82
+ return cached;
83
+ }
84
+ const candidates = this.#stylesByRole.get(role) ?? this.#stylesByRole.get('regular');
85
+ const resolved = candidates.reduce((best, candidate) => {
86
+ const distance = Math.abs(candidate.logicalSize - size);
87
+ const bestDistance = Math.abs(best.logicalSize - size);
88
+ return distance < bestDistance
89
+ || (distance === bestDistance && candidate.logicalSize > best.logicalSize)
90
+ ? candidate
91
+ : best;
92
+ });
93
+ this.#styleCache.set(key, resolved);
94
+ return resolved;
95
+ }
96
+
97
+ glyph(character, role = 'regular', size = 10) {
98
+ const style = this.style(role, size);
99
+ return style.glyphs.get(character) ?? style.glyphs.get('?');
100
+ }
101
+
102
+ metrics(size, role = 'regular') {
103
+ const style = this.style(role, size);
104
+ const key = `${style.id}\0${size}`;
105
+ const cached = this.#metricsCache.get(key);
106
+ if (cached) {
107
+ return cached;
108
+ }
109
+ const scale = size / style.logicalSize / this.#pixelRatio;
110
+ const metrics = {
111
+ style: style.id,
112
+ ascent: style.ascent * scale,
113
+ descent: style.descent * scale,
114
+ lineHeight: style.lineHeight * scale
115
+ };
116
+ this.#metricsCache.set(key, metrics);
117
+ return metrics;
118
+ }
119
+
120
+ measure(text, size, role = 'regular') {
121
+ const style = this.style(role, size);
122
+ const value = String(text);
123
+ const key = `${style.id}\0${size}\0${value}`;
124
+ const cached = this.#measureCache.get(key);
125
+ if (cached !== undefined) {
126
+ return cached;
127
+ }
128
+ const scale = size / style.logicalSize / this.#pixelRatio;
129
+ let width = 0;
130
+ for (const character of value) {
131
+ width += (style.glyphs.get(character) ?? style.glyphs.get('?')).advance * scale;
132
+ }
133
+ if (this.#measureCache.size >= 8192) {
134
+ this.#measureCache.delete(this.#measureCache.keys().next().value);
135
+ }
136
+ this.#measureCache.set(key, width);
137
+ return width;
138
+ }
139
+
140
+ async initialize() {
141
+ await this.#loadFont();
142
+ const measurementCanvas = makeCanvas(1, 1);
143
+ const measurement = measurementCanvas.getContext('2d', { alpha: true });
144
+ measurement.textBaseline = 'alphabetic';
145
+ measurement.fontKerning = 'none';
146
+ const padding = Math.max(2, Math.ceil(this.#pixelRatio));
147
+ const styles = STYLE_DEFINITIONS.map((definition) => this.#measureStyle(
148
+ measurement,
149
+ definition,
150
+ padding
151
+ ));
152
+ const atlasWidth = 2048;
153
+ let x = 1;
154
+ let y = 1;
155
+ let rowHeight = 0;
156
+ for (const style of styles) {
157
+ for (const glyph of style.glyphs.values()) {
158
+ if (x + glyph.width + 1 > atlasWidth) {
159
+ x = 1;
160
+ y += rowHeight + 1;
161
+ rowHeight = 0;
162
+ }
163
+ glyph.x = x;
164
+ glyph.y = y;
165
+ x += glyph.width + 1;
166
+ rowHeight = Math.max(rowHeight, glyph.height);
167
+ }
168
+ }
169
+ const atlasHeight = nextPowerOfTwo(y + rowHeight + 1);
170
+ const maxDimension = this.#device.limits?.maxTextureDimension2D ?? 8192;
171
+ if (atlasHeight > maxDimension || atlasWidth > maxDimension) {
172
+ throw new Error(`MultiGauge text atlas exceeds the GPU texture limit (${atlasWidth}×${atlasHeight}).`);
173
+ }
174
+
175
+ const canvas = makeCanvas(atlasWidth, atlasHeight);
176
+ const context = canvas.getContext('2d', { alpha: true });
177
+ context.clearRect(0, 0, atlasWidth, atlasHeight);
178
+ context.fillStyle = '#ffffff';
179
+ context.textBaseline = 'alphabetic';
180
+ context.fontKerning = 'none';
181
+ for (const style of styles) {
182
+ context.font = fontString(style, this.#pixelRatio);
183
+ for (const [character, glyph] of style.glyphs) {
184
+ if (character !== ' ') {
185
+ context.fillText(
186
+ character,
187
+ glyph.x + padding + glyph.left,
188
+ glyph.y + padding + glyph.ascent
189
+ );
190
+ }
191
+ glyph.u0 = glyph.x / atlasWidth;
192
+ glyph.v0 = glyph.y / atlasHeight;
193
+ glyph.u1 = (glyph.x + glyph.width) / atlasWidth;
194
+ glyph.v1 = (glyph.y + glyph.height) / atlasHeight;
195
+ }
196
+ this.#styles.set(style.id, style);
197
+ const roleStyles = this.#stylesByRole.get(style.role) ?? [];
198
+ roleStyles.push(style);
199
+ roleStyles.sort((a, b) => a.logicalSize - b.logicalSize);
200
+ this.#stylesByRole.set(style.role, roleStyles);
201
+ }
202
+
203
+ this.#texture = this.#device.createTexture({
204
+ label: `MultiGauge Inter glyph atlas @${this.#pixelRatio}x`,
205
+ size: [atlasWidth, atlasHeight],
206
+ format: 'rgba8unorm',
207
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST
208
+ | GPUTextureUsage.RENDER_ATTACHMENT
209
+ });
210
+ this.#device.queue.copyExternalImageToTexture(
211
+ { source: canvas },
212
+ { texture: this.#texture },
213
+ [atlasWidth, atlasHeight]
214
+ );
215
+ this.#view = this.#texture.createView();
216
+ this.#sampler = this.#device.createSampler({
217
+ magFilter: 'linear',
218
+ minFilter: 'linear',
219
+ addressModeU: 'clamp-to-edge',
220
+ addressModeV: 'clamp-to-edge'
221
+ });
222
+ }
223
+
224
+ destroy() {
225
+ this.#texture?.destroy();
226
+ this.#styleCache.clear();
227
+ this.#metricsCache.clear();
228
+ this.#measureCache.clear();
229
+ if (this.#fontFace && globalThis.document?.fonts) {
230
+ document.fonts.delete(this.#fontFace);
231
+ }
232
+ }
233
+
234
+ async #loadFont() {
235
+ if (typeof FontFace !== 'function' || !globalThis.document?.fonts) {
236
+ throw new Error('MultiGauge requires the CSS Font Loading API to build its Inter atlas.');
237
+ }
238
+ const face = new FontFace(FONT_FAMILY, `url("${FONT_URL.href}") format("woff2")`, {
239
+ style: 'normal',
240
+ weight: '100 900'
241
+ });
242
+ this.#fontFace = await face.load();
243
+ document.fonts.add(this.#fontFace);
244
+ await Promise.all([...new Set(STYLE_DEFINITIONS.map(({ weight }) => weight))]
245
+ .map((weight) => document.fonts.load(`${weight} 16px "${FONT_FAMILY}"`, 'Hgm0123°µ·')));
246
+ await document.fonts.ready;
247
+ for (const weight of new Set(STYLE_DEFINITIONS.map((style) => style.weight))) {
248
+ if (!document.fonts.check(`${weight} 16px "${FONT_FAMILY}"`, 'Hgm0123°µ·')) {
249
+ throw new Error(`Inter Variable weight ${weight} did not load for the MultiGauge atlas.`);
250
+ }
251
+ }
252
+ }
253
+
254
+ #measureStyle(context, definition, padding) {
255
+ const style = { ...definition, glyphs: new Map() };
256
+ context.font = fontString(style, this.#pixelRatio);
257
+ const records = [];
258
+ for (const character of CHARACTERS) {
259
+ const metrics = context.measureText(character);
260
+ const left = finiteMetric(metrics.actualBoundingBoxLeft);
261
+ const right = finiteMetric(metrics.actualBoundingBoxRight, metrics.width);
262
+ const ascent = Math.max(0, finiteMetric(metrics.actualBoundingBoxAscent,
263
+ style.logicalSize * this.#pixelRatio * 0.8));
264
+ const descent = Math.max(0, finiteMetric(metrics.actualBoundingBoxDescent,
265
+ style.logicalSize * this.#pixelRatio * 0.2));
266
+ const inkWidth = character === ' ' ? 0 : Math.max(1, Math.ceil(left + right));
267
+ const inkHeight = character === ' ' ? 0 : Math.max(1, Math.ceil(ascent + descent));
268
+ const record = {
269
+ character,
270
+ left,
271
+ ascent,
272
+ descent,
273
+ bearingLeft: left + padding,
274
+ bearingTop: ascent + padding,
275
+ width: Math.max(1, inkWidth + padding * 2),
276
+ height: Math.max(1, inkHeight + padding * 2),
277
+ advance: Math.max(1, metrics.width),
278
+ offsetX: 0
279
+ };
280
+ records.push(record);
281
+ style.glyphs.set(character, record);
282
+ }
283
+ if (style.tabular) {
284
+ const digitAdvance = Math.max(...[...'0123456789']
285
+ .map((digit) => style.glyphs.get(digit).advance));
286
+ for (const digit of '0123456789') {
287
+ const glyph = style.glyphs.get(digit);
288
+ glyph.offsetX = (digitAdvance - glyph.advance) / 2;
289
+ glyph.advance = digitAdvance;
290
+ }
291
+ }
292
+ const sample = context.measureText('Hgjpqy°µ');
293
+ style.ascent = Math.max(
294
+ finiteMetric(sample.fontBoundingBoxAscent),
295
+ ...records.map((record) => record.ascent)
296
+ );
297
+ style.descent = Math.max(
298
+ finiteMetric(sample.fontBoundingBoxDescent),
299
+ ...records.map((record) => record.descent)
300
+ );
301
+ style.lineHeight = style.ascent + style.descent;
302
+ return style;
303
+ }
304
+ }
@@ -0,0 +1,212 @@
1
+ export const SHAPE_SHADER = /* wgsl */ `
2
+ struct Viewport {
3
+ size: vec2f,
4
+ _padding: vec2f,
5
+ }
6
+
7
+ struct VertexOutput {
8
+ @builtin(position) position: vec4f,
9
+ @location(0) local: vec2f,
10
+ @location(1) @interpolate(flat) instance: u32,
11
+ }
12
+
13
+ @group(0) @binding(0) var<storage, read> shapes: array<vec4f>;
14
+ @group(0) @binding(1) var<storage, read> dynamics: array<vec4f>;
15
+ @group(0) @binding(2) var<uniform> viewport: Viewport;
16
+
17
+ @vertex
18
+ fn vertexMain(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> VertexOutput {
19
+ let corners = array<vec2f, 6>(
20
+ vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
21
+ vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0)
22
+ );
23
+ let geometry = shapes[instance * 5u];
24
+ let local = corners[vertex];
25
+ let pixel = geometry.xy + local * geometry.zw;
26
+ var output: VertexOutput;
27
+ output.position = vec4f(pixel.x / viewport.size.x * 2.0 - 1.0, 1.0 - pixel.y / viewport.size.y * 2.0, 0.0, 1.0);
28
+ output.local = local;
29
+ output.instance = instance;
30
+ return output;
31
+ }
32
+
33
+ fn roundedBox(point: vec2f, halfSize: vec2f, radius: f32) -> f32 {
34
+ let q = abs(point) - halfSize + vec2f(radius);
35
+ return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - radius;
36
+ }
37
+
38
+ fn coverage(distance: f32, antialias: f32) -> f32 {
39
+ return 1.0 - smoothstep(-antialias, antialias, distance);
40
+ }
41
+
42
+ @fragment
43
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
44
+ let base = input.instance * 5u;
45
+ let geometry = shapes[base];
46
+ var rgba = shapes[base + 1u];
47
+ let parameters = shapes[base + 2u];
48
+ let extra = shapes[base + 3u];
49
+ let kind = u32(parameters.x + 0.5);
50
+ let gaugeIndex = u32(max(extra.y, 0.0) + 0.5);
51
+ let dynamicMode = u32(extra.z + 0.5);
52
+ let dynamic = dynamics[gaugeIndex * 2u];
53
+ let dynamicColor = dynamics[gaugeIndex * 2u + 1u];
54
+ let halfSize = geometry.zw;
55
+ let point = input.local * halfSize;
56
+ // This unconditional derivative keeps the transition close to one physical pixel.
57
+ let antialias = max(max(fwidth(point.x), fwidth(point.y)) * 0.5, 0.001);
58
+ var alpha = 0.0;
59
+
60
+ if (kind == 0u) {
61
+ let radius = parameters.y;
62
+ let border = parameters.z;
63
+ let distance = roundedBox(point, halfSize, radius);
64
+ alpha = coverage(distance, antialias);
65
+ if (border > 0.0) {
66
+ let inner = roundedBox(point, halfSize - vec2f(border), max(0.0, radius - border));
67
+ alpha = alpha * (1.0 - coverage(inner, antialias));
68
+ }
69
+ if (dynamicMode == 2u && input.local.x > dynamic.x * 2.0 - 1.0) {
70
+ discard;
71
+ }
72
+ if (dynamicMode == 3u && input.local.y < 1.0 - dynamic.x * 2.0) {
73
+ discard;
74
+ }
75
+ if (dynamicMode == 6u) {
76
+ rgba = dynamicColor;
77
+ }
78
+ } else if (kind == 1u) {
79
+ let radius = length(point);
80
+ let inner = parameters.w;
81
+ let outer = extra.x;
82
+ var endAngle = parameters.z;
83
+ if (dynamicMode == 1u) {
84
+ endAngle = mix(parameters.y, parameters.z, dynamic.x);
85
+ }
86
+ let angle = atan2(point.x, -point.y);
87
+ let tau = 6.28318530718;
88
+ var relative = angle - parameters.y;
89
+ relative = relative - floor(relative / tau) * tau;
90
+ var span = endAngle - parameters.y;
91
+ span = span - floor(span / tau) * tau;
92
+ if (abs(endAngle - parameters.y) >= tau - 0.001) {
93
+ span = tau;
94
+ }
95
+ let ring = max(inner - radius, radius - outer);
96
+ alpha = coverage(ring, antialias);
97
+ if (relative > span) {
98
+ discard;
99
+ }
100
+ } else if (kind == 2u) {
101
+ let distance = roundedBox(point, halfSize, min(halfSize.x, halfSize.y));
102
+ alpha = coverage(distance, antialias);
103
+ if (dynamicMode == 2u && input.local.x > dynamic.x * 2.0 - 1.0) {
104
+ discard;
105
+ }
106
+ if (dynamicMode == 3u && input.local.y < 1.0 - dynamic.x * 2.0) {
107
+ discard;
108
+ }
109
+ } else if (kind == 3u) {
110
+ var position = dynamic.x;
111
+ if (dynamicMode == 4u) {
112
+ let marker = abs(point.x - mix(-halfSize.x, halfSize.x, position));
113
+ alpha = coverage(marker - parameters.y, antialias);
114
+ } else {
115
+ let marker = abs(point.y - mix(halfSize.y, -halfSize.y, position));
116
+ alpha = coverage(marker - parameters.y, antialias);
117
+ }
118
+ } else if (kind == 4u) {
119
+ let angle = dynamic.x * 6.28318530718;
120
+ let direction = vec2f(sin(angle), -cos(angle));
121
+ let along = dot(point, direction);
122
+ let across = abs(point.x * direction.y - point.y * direction.x);
123
+ let distance = max(across - parameters.y, max(-along, along - parameters.z));
124
+ alpha = coverage(distance, antialias);
125
+ } else if (kind == 5u) {
126
+ let distance = length(point) - parameters.y;
127
+ alpha = coverage(distance, antialias);
128
+ if (dynamicMode == 6u) {
129
+ rgba = dynamicColor;
130
+ }
131
+ } else if (kind == 6u) {
132
+ let angle = dynamic.x * 6.28318530718;
133
+ let direction = vec2f(sin(angle), -cos(angle));
134
+ let along = dot(point, direction);
135
+ let across = point.x * direction.y - point.y * direction.x;
136
+ let markerPoint = vec2f(across, along - parameters.z);
137
+ let distance = roundedBox(
138
+ markerPoint,
139
+ vec2f(parameters.y, parameters.w),
140
+ parameters.y
141
+ );
142
+ alpha = coverage(distance, antialias);
143
+ } else if (kind == 7u) {
144
+ let angle = extra.x;
145
+ let direction = vec2f(sin(angle), -cos(angle));
146
+ let along = dot(point, direction);
147
+ let across = point.x * direction.y - point.y * direction.x;
148
+ let markerPoint = vec2f(across, along - parameters.z);
149
+ let distance = roundedBox(
150
+ markerPoint,
151
+ vec2f(parameters.y, parameters.w),
152
+ parameters.y
153
+ );
154
+ alpha = coverage(distance, antialias);
155
+ }
156
+
157
+ if (alpha <= 0.0) {
158
+ discard;
159
+ }
160
+ return vec4f(rgba.rgb, rgba.a * alpha);
161
+ }
162
+ `;
163
+
164
+ export const TEXTURE_SHADER = /* wgsl */ `
165
+ struct Viewport {
166
+ size: vec2f,
167
+ _padding: vec2f,
168
+ }
169
+
170
+ struct VertexOutput {
171
+ @builtin(position) position: vec4f,
172
+ @location(0) uv: vec2f,
173
+ @location(1) color: vec4f,
174
+ }
175
+
176
+ @group(0) @binding(0) var<storage, read> sprites: array<vec4f>;
177
+ @group(0) @binding(1) var<uniform> viewport: Viewport;
178
+ @group(0) @binding(2) var atlas: texture_2d<f32>;
179
+ @group(0) @binding(3) var atlasSampler: sampler;
180
+
181
+ @vertex
182
+ fn vertexMain(@builtin(vertex_index) vertex: u32, @builtin(instance_index) instance: u32) -> VertexOutput {
183
+ let corners = array<vec2f, 6>(
184
+ vec2f(-1.0, -1.0), vec2f(1.0, -1.0), vec2f(-1.0, 1.0),
185
+ vec2f(-1.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0)
186
+ );
187
+ let base = instance * 4u;
188
+ let geometry = sprites[base];
189
+ let uvRect = sprites[base + 1u];
190
+ let rgba = sprites[base + 2u];
191
+ let transform = sprites[base + 3u];
192
+ let local = corners[vertex];
193
+ let offset = local * geometry.zw;
194
+ let rotated = vec2f(
195
+ offset.x * transform.x - offset.y * transform.y,
196
+ offset.x * transform.y + offset.y * transform.x
197
+ );
198
+ let pixel = geometry.xy + rotated;
199
+ let unit = local * 0.5 + vec2f(0.5);
200
+ var output: VertexOutput;
201
+ output.position = vec4f(pixel.x / viewport.size.x * 2.0 - 1.0, 1.0 - pixel.y / viewport.size.y * 2.0, 0.0, 1.0);
202
+ output.uv = mix(uvRect.xy, uvRect.zw, unit);
203
+ output.color = rgba;
204
+ return output;
205
+ }
206
+
207
+ @fragment
208
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
209
+ let sampled = textureSample(atlas, atlasSampler, input.uv);
210
+ return vec4f(input.color.rgb, input.color.a * sampled.a);
211
+ }
212
+ `;
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { MultiGauge } from './MultiGauge.js';
2
+ export { MultiGaugeError } from './errors.js';