@project-gridmap/library 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Project Gridmap contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # Project Gridmap
2
+
3
+ Project Gridmap is a content-agnostic hierarchical grid map renderer. It knows
4
+ about layers, groups, items, and cells. It does not know what those things mean.
5
+
6
+ The current root `index.html` is still the standalone Bible demo for GitHub
7
+ Pages. The importable package in `src/` is deliberately generic so it can power
8
+ that demo, The Bible Game, and other ordered datasets.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @project-gridmap/core
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```js
19
+ import { createGridmap } from '@project-gridmap/core';
20
+
21
+ const map = createGridmap({
22
+ container: document.getElementById('map'),
23
+ data: {
24
+ layers: [
25
+ {
26
+ id: 'course',
27
+ label: 'Course',
28
+ groups: [
29
+ {
30
+ id: 'module-a',
31
+ label: 'Module A',
32
+ items: [
33
+ {
34
+ id: 'lesson-1',
35
+ label: 'Lesson 1',
36
+ shortLabel: 'L1',
37
+ cells: [
38
+ { id: 'lesson-1.1', label: '1', value: 12 },
39
+ { id: 'lesson-1.2', label: '2', value: 8 },
40
+ ],
41
+ },
42
+ ],
43
+ },
44
+ ],
45
+ },
46
+ ],
47
+ },
48
+ colours: {
49
+ 'lesson-1': '#c6feff',
50
+ },
51
+ onSelectCell(cell) {
52
+ console.log(cell.id);
53
+ },
54
+ });
55
+ ```
56
+
57
+ ## Data Model
58
+
59
+ Gridmap uses a fixed neutral hierarchy:
60
+
61
+ ```txt
62
+ layer > group > item > cell
63
+ ```
64
+
65
+ The order of the input data is preserved. Cell `value` is optional and is used
66
+ for relative mark sizing when `relativeMarkSize` is enabled.
67
+
68
+ ## API
69
+
70
+ ```js
71
+ map.focusMap();
72
+ map.focusGroup('module-a');
73
+ map.focusItem('lesson-1');
74
+ map.focusCell('lesson-1.2');
75
+ map.selectCell('lesson-1.2');
76
+ map.addLayer((helpers) => {});
77
+ map.setData(data);
78
+ map.setColours(colours);
79
+ map.destroy();
80
+ ```
81
+
82
+ Custom layers let applications draw domain-specific overlays without adding
83
+ domain language to the library.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@project-gridmap/library",
3
+ "version": "0.1.0",
4
+ "description": "A content-agnostic hierarchical grid map renderer.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "module": "./src/index.js",
8
+ "types": "./src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "test": "node --test",
23
+ "typecheck": "node --check src/index.js && node --check src/model.js && node --check src/gridmap.js"
24
+ },
25
+ "keywords": [
26
+ "gridmap",
27
+ "treemap",
28
+ "visualization",
29
+ "canvas"
30
+ ],
31
+ "license": "MIT"
32
+ }
package/src/gridmap.js ADDED
@@ -0,0 +1,682 @@
1
+ 'use strict';
2
+
3
+ import { DEFAULT_WORLDS, buildGridmapModel, cellAt, normaliseGridmapData } from './model.js';
4
+
5
+ const DEFAULT_THEME = {
6
+ background: '#0a0b0c',
7
+ text: '#f2efe8',
8
+ mutedText: '#a19d94',
9
+ faintText: '#5c5954',
10
+ cellLine: '#2a2c2e',
11
+ itemLine: '#7d7a74',
12
+ groupLine: '#aeaaa2',
13
+ layerLine: '#d9d5cd',
14
+ font: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace',
15
+ };
16
+
17
+ const DEFAULT_OPTIONS = {
18
+ worlds: DEFAULT_WORLDS,
19
+ layout: 'auto',
20
+ colours: {},
21
+ colourBy: 'item',
22
+ showMarks: true,
23
+ markType: 'number',
24
+ markOpacity: 0.5,
25
+ relativeMarkSize: true,
26
+ markMaxSize: 0.4,
27
+ numberMinPx: 8,
28
+ itemLabels: 'short',
29
+ history: false,
30
+ theme: DEFAULT_THEME,
31
+ labels: {},
32
+ };
33
+
34
+ function mergeOptions(options) {
35
+ return {
36
+ ...DEFAULT_OPTIONS,
37
+ ...options,
38
+ worlds: { ...DEFAULT_WORLDS, ...(options.worlds ?? {}) },
39
+ theme: { ...DEFAULT_THEME, ...(options.theme ?? {}) },
40
+ labels: { ...(options.labels ?? {}) },
41
+ };
42
+ }
43
+
44
+ function createElement(tag, attrs = {}, parent) {
45
+ const el = document.createElement(tag);
46
+ for (const [key, value] of Object.entries(attrs)) {
47
+ if (key === 'class') el.className = value;
48
+ else if (key === 'text') el.textContent = value;
49
+ else el.setAttribute(key, value);
50
+ }
51
+ if (parent) parent.appendChild(el);
52
+ return el;
53
+ }
54
+
55
+ const median = (values, fallback = 1) => {
56
+ const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
57
+ return sorted.length ? sorted[sorted.length >> 1] : fallback;
58
+ };
59
+
60
+ export class Gridmap {
61
+ constructor(options) {
62
+ if (!options?.container) throw new TypeError('createGridmap requires a container element');
63
+ if (!options?.data) throw new TypeError('createGridmap requires data');
64
+
65
+ this.options = mergeOptions(options);
66
+ this.data = normaliseGridmapData(options.data);
67
+ this.container = options.container;
68
+ this.models = {};
69
+ this.model = null;
70
+ this.camera = { cx: 0, cy: 0, k: 1, free: false };
71
+ this.state = { hover: null, selected: null, focus: { level: 'map' } };
72
+ this.layers = new Set();
73
+ this.listeners = new Map();
74
+ this.frameRequested = false;
75
+ this.destroyed = false;
76
+ this.pointer = { x: 0, y: 0, inside: false, down: false, moved: false };
77
+ this.resizeObserver = null;
78
+
79
+ this.mount();
80
+ this.rebuildModels();
81
+ this.useLayout(this.layoutName());
82
+ this.bind();
83
+ this.render();
84
+ this.emit('ready', this);
85
+ }
86
+
87
+ mount() {
88
+ const style = getComputedStyle(this.container);
89
+ if (style.position === 'static') this.container.style.position = 'relative';
90
+ this.container.style.overflow = 'hidden';
91
+ this.container.style.background = this.options.theme.background;
92
+
93
+ this.root = createElement('div', { class: 'gridmap-root' }, this.container);
94
+ Object.assign(this.root.style, {
95
+ position: 'absolute',
96
+ inset: '0',
97
+ overflow: 'hidden',
98
+ touchAction: 'none',
99
+ userSelect: 'none',
100
+ fontFamily: this.options.theme.font,
101
+ color: this.options.theme.text,
102
+ });
103
+
104
+ this.canvas = createElement('canvas', { class: 'gridmap-canvas' }, this.root);
105
+ Object.assign(this.canvas.style, { position: 'absolute', inset: '0', width: '100%', height: '100%' });
106
+ this.ctx = this.canvas.getContext('2d');
107
+
108
+ this.overlay = createElement('div', { class: 'gridmap-overlay' }, this.root);
109
+ Object.assign(this.overlay.style, {
110
+ position: 'absolute',
111
+ inset: '0',
112
+ pointerEvents: 'none',
113
+ fontFamily: this.options.theme.font,
114
+ fontSize: '10px',
115
+ letterSpacing: '0.14em',
116
+ textTransform: 'uppercase',
117
+ });
118
+
119
+ this.tooltip = createElement('div', { class: 'gridmap-tooltip' }, this.root);
120
+ Object.assign(this.tooltip.style, {
121
+ position: 'absolute',
122
+ pointerEvents: 'none',
123
+ opacity: '0',
124
+ padding: '5px 8px',
125
+ background: this.options.theme.background,
126
+ borderLeft: `1px solid ${this.options.theme.itemLine}`,
127
+ color: this.options.theme.mutedText,
128
+ fontSize: '10px',
129
+ letterSpacing: '0.14em',
130
+ textTransform: 'uppercase',
131
+ lineHeight: '1.6',
132
+ transition: 'opacity 120ms',
133
+ });
134
+ }
135
+
136
+ bind() {
137
+ this.bound = {
138
+ resize: () => this.resize(),
139
+ pointerdown: (event) => this.onPointerDown(event),
140
+ pointermove: (event) => this.onPointerMove(event),
141
+ pointerup: (event) => this.onPointerUp(event),
142
+ pointerleave: (event) => this.onPointerLeave(event),
143
+ wheel: (event) => this.onWheel(event),
144
+ keydown: (event) => {
145
+ if (event.key === 'Escape') this.focusMap();
146
+ },
147
+ };
148
+
149
+ this.root.addEventListener('pointerdown', this.bound.pointerdown);
150
+ this.root.addEventListener('pointermove', this.bound.pointermove);
151
+ this.root.addEventListener('pointerup', this.bound.pointerup);
152
+ this.root.addEventListener('pointercancel', this.bound.pointerup);
153
+ this.root.addEventListener('pointerleave', this.bound.pointerleave);
154
+ this.root.addEventListener('wheel', this.bound.wheel, { passive: false });
155
+ window.addEventListener('keydown', this.bound.keydown);
156
+
157
+ if ('ResizeObserver' in window) {
158
+ this.resizeObserver = new ResizeObserver(this.bound.resize);
159
+ this.resizeObserver.observe(this.container);
160
+ } else {
161
+ window.addEventListener('resize', this.bound.resize);
162
+ }
163
+ }
164
+
165
+ rebuildModels() {
166
+ this.models = {};
167
+ for (const [name, world] of Object.entries(this.options.worlds)) {
168
+ const model = buildGridmapModel(this.data, world);
169
+ model.layout = name;
170
+ this.models[name] = model;
171
+ }
172
+ }
173
+
174
+ layoutName() {
175
+ if (this.options.layout !== 'auto') return this.options.layout;
176
+ return this.container.clientHeight > this.container.clientWidth ? 'portrait' : 'landscape';
177
+ }
178
+
179
+ useLayout(name) {
180
+ const old = this.model;
181
+ this.model = this.models[name] ?? Object.values(this.models)[0];
182
+ if (old && old !== this.model) {
183
+ const same = (collection, entity) => (entity ? this.model[collection][entity.index] : null);
184
+ this.state.hover = null;
185
+ this.state.selected = same('cells', this.state.selected);
186
+ const focus = this.state.focus;
187
+ this.state.focus = {
188
+ level: focus.level,
189
+ group: same('groups', focus.group),
190
+ item: same('items', focus.item),
191
+ cell: same('cells', focus.cell),
192
+ };
193
+ }
194
+ this.prepareMarks();
195
+ Object.assign(this.camera, this.cameraForFocus(), { free: false });
196
+ this.resize();
197
+ }
198
+
199
+ prepareMarks() {
200
+ const values = this.model.cells.map((cell) => cell.value);
201
+ this.medianValue = median(values);
202
+ const sortedValues = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
203
+ this.referenceValue = sortedValues[Math.floor(Math.max(0, sortedValues.length - 1) * 0.99)] ?? this.medianValue;
204
+ }
205
+
206
+ viewport() {
207
+ return {
208
+ w: Math.max(1, this.container.clientWidth),
209
+ h: Math.max(1, this.container.clientHeight),
210
+ };
211
+ }
212
+
213
+ resize() {
214
+ if (this.destroyed || !this.model) return;
215
+ const nextLayout = this.layoutName();
216
+ if (nextLayout !== this.model.layout) {
217
+ this.useLayout(nextLayout);
218
+ return;
219
+ }
220
+
221
+ const { w, h } = this.viewport();
222
+ const dpr = Math.min(window.devicePixelRatio || 1, 3);
223
+ this.canvas.width = Math.round(w * dpr);
224
+ this.canvas.height = Math.round(h * dpr);
225
+ this.dpr = dpr;
226
+ if (!this.camera.free) Object.assign(this.camera, this.cameraForFocus());
227
+ this.render();
228
+ }
229
+
230
+ on(type, listener) {
231
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
232
+ this.listeners.get(type).add(listener);
233
+ return () => this.off(type, listener);
234
+ }
235
+
236
+ off(type, listener) {
237
+ this.listeners.get(type)?.delete(listener);
238
+ }
239
+
240
+ emit(type, payload) {
241
+ this.listeners.get(type)?.forEach((listener) => listener(payload, this));
242
+ const callback = this.options[`on${type[0].toUpperCase()}${type.slice(1)}`];
243
+ if (typeof callback === 'function') callback(payload, this);
244
+ }
245
+
246
+ addLayer(layer) {
247
+ this.layers.add(layer);
248
+ this.render();
249
+ return () => {
250
+ this.layers.delete(layer);
251
+ this.render();
252
+ };
253
+ }
254
+
255
+ setData(data) {
256
+ this.data = normaliseGridmapData(data);
257
+ this.rebuildModels();
258
+ this.useLayout(this.layoutName());
259
+ this.emit('dataChange', this.data);
260
+ }
261
+
262
+ setColours(colours = {}) {
263
+ this.options.colours = colours;
264
+ this.render();
265
+ }
266
+
267
+ setConfig(config = {}) {
268
+ this.options = mergeOptions({ ...this.options, ...config });
269
+ this.prepareMarks();
270
+ this.render();
271
+ }
272
+
273
+ getModel() {
274
+ return this.model;
275
+ }
276
+
277
+ getSelectedCell() {
278
+ return this.state.selected;
279
+ }
280
+
281
+ colourOf(entity, fallback = this.options.theme.itemLine) {
282
+ const key = this.options.colourBy === 'group' ? entity.groupId
283
+ : this.options.colourBy === 'layer' ? entity.layerId
284
+ : this.options.colourBy === 'cell' ? entity.id
285
+ : entity.itemId ?? entity.id;
286
+ return this.options.colours?.[key] ?? fallback;
287
+ }
288
+
289
+ toScreenX(x) {
290
+ const { w } = this.viewport();
291
+ return (x - this.camera.cx) * this.camera.k + w / 2;
292
+ }
293
+
294
+ toScreenY(y) {
295
+ const { h } = this.viewport();
296
+ return (y - this.camera.cy) * this.camera.k + h / 2;
297
+ }
298
+
299
+ toWorldX(x) {
300
+ const { w } = this.viewport();
301
+ return (x - w / 2) / this.camera.k + this.camera.cx;
302
+ }
303
+
304
+ toWorldY(y) {
305
+ const { h } = this.viewport();
306
+ return (y - h / 2) / this.camera.k + this.camera.cy;
307
+ }
308
+
309
+ frame(rect, fill = 0.92) {
310
+ const { w, h } = this.viewport();
311
+ const margin = Math.min(w, h) < 700 ? 24 : 56;
312
+ const availableW = Math.max(1, w - margin * 2);
313
+ const availableH = Math.max(1, h - margin * 2);
314
+ const k = Math.min(availableW / rect.width, availableH / rect.height, (w * fill) / rect.width, (h * fill) / rect.height);
315
+ return { cx: rect.x + rect.width / 2, cy: rect.y + rect.height / 2, k };
316
+ }
317
+
318
+ clampK(k) {
319
+ const min = this.frame(this.model.world).k * 0.55;
320
+ const max = Math.min(this.viewport().w, this.viewport().h) * 0.9 / this.model.cellSide;
321
+ return Math.min(Math.max(k, min), max);
322
+ }
323
+
324
+ setCamera(cx, cy, k) {
325
+ const world = this.model.world;
326
+ this.camera.k = this.clampK(k);
327
+ this.camera.cx = Math.min(Math.max(cx, world.x), world.x + world.width);
328
+ this.camera.cy = Math.min(Math.max(cy, world.y), world.y + world.height);
329
+ this.camera.free = true;
330
+ this.render();
331
+ }
332
+
333
+ zoomAround(sx, sy, wx, wy, k) {
334
+ k = this.clampK(k);
335
+ this.setCamera(wx - (sx - this.viewport().w / 2) / k, wy - (sy - this.viewport().h / 2) / k, k);
336
+ }
337
+
338
+ focusRegion() {
339
+ const focus = this.state.focus;
340
+ if (focus.level === 'cell') return [focus.cell];
341
+ if (focus.level === 'item') return [focus.item];
342
+ if (focus.level === 'group') return focus.group.items;
343
+ return null;
344
+ }
345
+
346
+ cameraForFocus() {
347
+ const focus = this.state.focus;
348
+ if (focus.level === 'cell') return this.frame(focus.cell, 0.42);
349
+ if (focus.level === 'item') return this.frame(focus.item, 0.72);
350
+ if (focus.level === 'group') return this.frame(focus.group.bounds, 0.9);
351
+ return this.frame(this.model.world);
352
+ }
353
+
354
+ setFocus(level, value = null) {
355
+ const focus = { level };
356
+ if (level === 'group') focus.group = typeof value === 'string'
357
+ ? this.model.groups.find((group) => group.uid === value || group.id === value)
358
+ : value;
359
+ if (level === 'item') focus.item = typeof value === 'string'
360
+ ? this.model.items.find((item) => item.id === value)
361
+ : value;
362
+ if (level === 'cell') focus.cell = typeof value === 'string'
363
+ ? this.model.cells.find((cell) => cell.id === value)
364
+ : value;
365
+ if ((level !== 'map' && !focus[level])) return false;
366
+ if (focus.item) focus.group = this.model.groups[focus.item.groupIndex];
367
+ if (focus.cell) {
368
+ focus.item = this.model.items[focus.cell.itemIndex];
369
+ focus.group = this.model.groups[focus.cell.groupIndex];
370
+ }
371
+ this.state.focus = focus;
372
+ Object.assign(this.camera, this.cameraForFocus(), { free: false });
373
+ this.emit('focusChange', focus);
374
+ this.render();
375
+ return true;
376
+ }
377
+
378
+ focusMap() { return this.setFocus('map'); }
379
+ focusGroup(idOrGroup) { return this.setFocus('group', idOrGroup); }
380
+ focusItem(idOrItem) { return this.setFocus('item', idOrItem); }
381
+ focusCell(idOrCell) { return this.setFocus('cell', idOrCell); }
382
+
383
+ selectCell(idOrCell) {
384
+ const cell = typeof idOrCell === 'string'
385
+ ? this.model.cells.find((candidate) => candidate.id === idOrCell)
386
+ : idOrCell;
387
+ if (!cell) return false;
388
+ this.state.selected = cell;
389
+ this.emit('selectCell', cell);
390
+ this.render();
391
+ return true;
392
+ }
393
+
394
+ hitTest(sx, sy) {
395
+ const cell = cellAt(this.model, this.toWorldX(sx), this.toWorldY(sy));
396
+ const focus = this.state.focus;
397
+ if (focus.level === 'cell') return cell === focus.cell ? cell : null;
398
+ if (focus.level === 'item') return cell && cell.itemIndex === focus.item.index ? cell : null;
399
+ if (focus.level === 'group') return cell && cell.groupIndex === focus.group.index ? cell : null;
400
+ return cell;
401
+ }
402
+
403
+ onPointerDown(event) {
404
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
405
+ this.pointer = {
406
+ x: event.clientX,
407
+ y: event.clientY,
408
+ startX: event.clientX,
409
+ startY: event.clientY,
410
+ cam: { ...this.camera },
411
+ inside: true,
412
+ down: true,
413
+ moved: false,
414
+ };
415
+ this.root.setPointerCapture?.(event.pointerId);
416
+ }
417
+
418
+ onPointerMove(event) {
419
+ const rect = this.root.getBoundingClientRect();
420
+ const x = event.clientX - rect.left;
421
+ const y = event.clientY - rect.top;
422
+
423
+ if (this.pointer.down) {
424
+ const dx = event.clientX - this.pointer.startX;
425
+ const dy = event.clientY - this.pointer.startY;
426
+ if (Math.hypot(dx, dy) > 4) this.pointer.moved = true;
427
+ if (this.pointer.moved) {
428
+ const cam = this.pointer.cam;
429
+ this.setCamera(cam.cx - dx / cam.k, cam.cy - dy / cam.k, cam.k);
430
+ }
431
+ return;
432
+ }
433
+
434
+ const hover = this.hitTest(x, y);
435
+ if (hover !== this.state.hover) {
436
+ this.state.hover = hover;
437
+ this.emit('hoverCell', hover);
438
+ this.render();
439
+ }
440
+ this.showTooltip(hover, x, y);
441
+ }
442
+
443
+ onPointerUp(event) {
444
+ if (!this.pointer.down) return;
445
+ const rect = this.root.getBoundingClientRect();
446
+ const x = event.clientX - rect.left;
447
+ const y = event.clientY - rect.top;
448
+ const wasTap = !this.pointer.moved;
449
+ this.pointer.down = false;
450
+ if (wasTap) {
451
+ const cell = this.hitTest(x, y);
452
+ if (cell) this.selectCell(cell);
453
+ else this.focusMap();
454
+ }
455
+ }
456
+
457
+ onPointerLeave() {
458
+ if (this.pointer.down) return;
459
+ this.state.hover = null;
460
+ this.showTooltip(null);
461
+ this.render();
462
+ }
463
+
464
+ onWheel(event) {
465
+ event.preventDefault();
466
+ const rect = this.root.getBoundingClientRect();
467
+ const x = event.clientX - rect.left;
468
+ const y = event.clientY - rect.top;
469
+ const dy = event.deltaY * (event.deltaMode === 1 ? 16 : 1);
470
+ this.zoomAround(x, y, this.toWorldX(x), this.toWorldY(y), this.camera.k * Math.exp(-dy * 0.0015));
471
+ }
472
+
473
+ showTooltip(cell, x, y) {
474
+ this.tooltip.style.opacity = cell ? '1' : '0';
475
+ if (!cell) return;
476
+ const format = this.options.labels.tooltip;
477
+ this.tooltip.textContent = typeof format === 'function'
478
+ ? format(cell, this)
479
+ : `${cell.groupLabel} / ${cell.itemLabel} / ${cell.label}`;
480
+ const width = this.tooltip.offsetWidth;
481
+ const height = this.tooltip.offsetHeight;
482
+ const { w, h } = this.viewport();
483
+ const left = x + 18 + width > w ? x - 18 - width : x + 18;
484
+ const top = y + 18 + height > h ? y - 18 - height : y + 18;
485
+ this.tooltip.style.transform = `translate(${Math.max(8, left)}px, ${Math.max(8, top)}px)`;
486
+ }
487
+
488
+ requestRender() {
489
+ if (this.frameRequested) return;
490
+ this.frameRequested = true;
491
+ requestAnimationFrame(() => {
492
+ this.frameRequested = false;
493
+ this.render();
494
+ });
495
+ }
496
+
497
+ render() {
498
+ if (!this.model || this.destroyed) return;
499
+ const { w, h } = this.viewport();
500
+ const ctx = this.ctx;
501
+ const theme = this.options.theme;
502
+ const k = this.camera.k;
503
+ const ox = w / 2 - this.camera.cx * k;
504
+ const oy = h / 2 - this.camera.cy * k;
505
+ const X = (x) => x * k + ox;
506
+ const Y = (y) => y * k + oy;
507
+ const rect = (r) => ctx.rect(X(r.x), Y(r.y), r.width * k, r.height * k);
508
+ const onScreen = (r) => X(r.x) < w && X(r.x + r.width) > 0 && Y(r.y) < h && Y(r.y + r.height) > 0;
509
+
510
+ ctx.setTransform(this.dpr || 1, 0, 0, this.dpr || 1, 0, 0);
511
+ ctx.clearRect(0, 0, w, h);
512
+ ctx.fillStyle = theme.background;
513
+ ctx.fillRect(0, 0, w, h);
514
+ ctx.lineWidth = 1;
515
+ ctx.setLineDash([]);
516
+
517
+ const visibleItems = this.model.items.filter(onScreen);
518
+
519
+ const hover = this.state.hover;
520
+ if (hover) {
521
+ ctx.fillStyle = theme.text;
522
+ ctx.globalAlpha = 0.07;
523
+ ctx.beginPath();
524
+ rect(hover);
525
+ ctx.fill();
526
+ ctx.globalAlpha = 1;
527
+ }
528
+
529
+ const lineAlpha = Math.min(1, Math.max(0, (this.model.cellSide * k - 6) / 6));
530
+ if (lineAlpha > 0) {
531
+ ctx.globalAlpha = lineAlpha;
532
+ ctx.strokeStyle = theme.cellLine;
533
+ ctx.beginPath();
534
+ for (const item of visibleItems) for (const cell of item.cells) if (onScreen(cell)) rect(cell);
535
+ ctx.stroke();
536
+ ctx.globalAlpha = 1;
537
+ }
538
+
539
+ for (const item of visibleItems) {
540
+ ctx.strokeStyle = this.colourOf(item, theme.itemLine);
541
+ ctx.globalAlpha = 0.9;
542
+ ctx.beginPath();
543
+ rect(item);
544
+ ctx.stroke();
545
+ }
546
+
547
+ ctx.globalAlpha = 1;
548
+ ctx.strokeStyle = theme.groupLine;
549
+ ctx.lineWidth = 1.25;
550
+ ctx.beginPath();
551
+ for (const group of this.model.groups) {
552
+ for (const segment of group.outline.filter((sg) => sg.betweenGroups)) {
553
+ ctx.moveTo(X(segment.x1), Y(segment.y1));
554
+ ctx.lineTo(X(segment.x2), Y(segment.y2));
555
+ }
556
+ }
557
+ ctx.stroke();
558
+
559
+ ctx.strokeStyle = theme.layerLine;
560
+ ctx.lineWidth = 1.5;
561
+ ctx.beginPath();
562
+ this.model.layers.forEach(rect);
563
+ ctx.stroke();
564
+
565
+ const selected = this.state.selected;
566
+ if (selected) {
567
+ const colour = this.colourOf(selected, theme.text);
568
+ ctx.strokeStyle = colour;
569
+ ctx.fillStyle = colour;
570
+ ctx.globalAlpha = 0.1;
571
+ ctx.beginPath();
572
+ rect(selected);
573
+ ctx.fill();
574
+ ctx.globalAlpha = 1;
575
+ ctx.lineWidth = 1.5;
576
+ ctx.beginPath();
577
+ rect(selected);
578
+ ctx.stroke();
579
+ }
580
+
581
+ if (this.options.showMarks) this.renderMarks(ctx, X, Y, onScreen);
582
+
583
+ for (const layer of this.layers) {
584
+ layer({
585
+ ctx,
586
+ model: this.model,
587
+ state: this.state,
588
+ camera: this.camera,
589
+ rect,
590
+ toScreenX: X,
591
+ toScreenY: Y,
592
+ colourOf: (entity, fallback) => this.colourOf(entity, fallback),
593
+ theme,
594
+ });
595
+ }
596
+
597
+ this.renderVeil(ctx, rect);
598
+ this.renderLabels(X, Y);
599
+ }
600
+
601
+ renderMarks(ctx, X, Y, onScreen) {
602
+ const theme = this.options.theme;
603
+ const useNumbers = this.options.markType === 'number' && this.model.cellSide * this.camera.k >= this.options.numberMinPx;
604
+ ctx.textAlign = 'center';
605
+ ctx.textBaseline = 'middle';
606
+ for (const item of this.model.items) {
607
+ ctx.fillStyle = this.colourOf(item, theme.text);
608
+ for (const cell of item.cells) {
609
+ if (!onScreen(cell)) continue;
610
+ const scale = this.options.relativeMarkSize && cell.value
611
+ ? Math.max(0.35, Math.sqrt(cell.value / this.referenceValue))
612
+ : 0.7;
613
+ const size = Math.min(cell.width, cell.height) * this.options.markMaxSize * scale * this.camera.k;
614
+ ctx.globalAlpha = cell === this.state.hover || cell === this.state.selected ? 1 : this.options.markOpacity;
615
+ if (useNumbers) {
616
+ ctx.font = `${cell === this.state.hover || cell === this.state.selected ? 500 : 400} ${Math.max(2, Math.min(24, size))}px ${theme.font}`;
617
+ ctx.fillText(cell.label, X(cell.centerX), Y(cell.centerY));
618
+ } else {
619
+ const radius = Math.min(4, Math.max(1.2, size / 8));
620
+ ctx.beginPath();
621
+ ctx.arc(X(cell.centerX), Y(cell.centerY), radius, 0, Math.PI * 2);
622
+ ctx.fill();
623
+ }
624
+ }
625
+ }
626
+ ctx.globalAlpha = 1;
627
+ }
628
+
629
+ renderVeil(ctx, rect) {
630
+ const region = this.focusRegion();
631
+ if (!region) return;
632
+ const { w, h } = this.viewport();
633
+ ctx.fillStyle = this.options.theme.background;
634
+ ctx.globalAlpha = 0.66;
635
+ ctx.beginPath();
636
+ ctx.rect(0, 0, w, h);
637
+ region.forEach(rect);
638
+ ctx.fill('evenodd');
639
+ ctx.globalAlpha = 1;
640
+ }
641
+
642
+ renderLabels(X, Y) {
643
+ const labelItem = this.options.labels.item;
644
+ const selected = this.state.selected;
645
+ this.overlay.replaceChildren();
646
+ for (const item of this.model.items) {
647
+ const w = item.width * this.camera.k;
648
+ const h = item.height * this.camera.k;
649
+ if (w < 28 || h < 14) continue;
650
+ const text = typeof labelItem === 'function'
651
+ ? labelItem(item, this)
652
+ : this.options.itemLabels === 'full' ? item.label : item.shortLabel;
653
+ const el = createElement('div', { text }, this.overlay);
654
+ Object.assign(el.style, {
655
+ position: 'absolute',
656
+ left: `${X(item.x) + 5}px`,
657
+ top: `${Y(item.y) - 6}px`,
658
+ maxWidth: `${Math.max(20, w - 10)}px`,
659
+ overflow: 'hidden',
660
+ whiteSpace: 'nowrap',
661
+ textOverflow: 'clip',
662
+ color: this.colourOf(item, this.options.theme.mutedText),
663
+ background: this.options.theme.background,
664
+ padding: '0 4px',
665
+ opacity: selected?.itemIndex === item.index ? '1' : '0.78',
666
+ });
667
+ }
668
+ }
669
+
670
+ destroy() {
671
+ this.destroyed = true;
672
+ this.resizeObserver?.disconnect();
673
+ window.removeEventListener('resize', this.bound?.resize);
674
+ window.removeEventListener('keydown', this.bound?.keydown);
675
+ this.root.remove();
676
+ this.listeners.clear();
677
+ }
678
+ }
679
+
680
+ export function createGridmap(options) {
681
+ return new Gridmap(options);
682
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,200 @@
1
+ export type GridmapCellInput = {
2
+ id?: string | number;
3
+ label?: string;
4
+ value?: number | null;
5
+ meta?: Record<string, unknown>;
6
+ };
7
+
8
+ export type GridmapItemInput = {
9
+ id?: string;
10
+ key?: string;
11
+ label?: string;
12
+ name?: string;
13
+ shortLabel?: string;
14
+ cells?: Array<GridmapCellInput | string | number>;
15
+ cellCount?: number;
16
+ cellsCount?: number;
17
+ meta?: Record<string, unknown>;
18
+ };
19
+
20
+ export type GridmapGroupInput = {
21
+ id?: string;
22
+ label?: string;
23
+ name?: string;
24
+ items: GridmapItemInput[];
25
+ meta?: Record<string, unknown>;
26
+ };
27
+
28
+ export type GridmapLayerInput = {
29
+ id?: string;
30
+ label?: string;
31
+ name?: string;
32
+ groups?: GridmapGroupInput[];
33
+ items?: GridmapItemInput[];
34
+ meta?: Record<string, unknown>;
35
+ };
36
+
37
+ export type GridmapData = {
38
+ layers: GridmapLayerInput[];
39
+ meta?: Record<string, unknown>;
40
+ };
41
+
42
+ export type GridmapRect = {
43
+ x: number;
44
+ y: number;
45
+ width: number;
46
+ height: number;
47
+ };
48
+
49
+ export type GridmapCell = GridmapRect & {
50
+ index: number;
51
+ id: string;
52
+ label: string;
53
+ value: number | null;
54
+ meta: Record<string, unknown>;
55
+ layerId: string;
56
+ layerLabel: string;
57
+ layerIndex: number;
58
+ groupId: string;
59
+ groupLabel: string;
60
+ groupIndex: number;
61
+ itemId: string;
62
+ itemLabel: string;
63
+ itemShortLabel: string;
64
+ itemIndex: number;
65
+ ordinal: number;
66
+ centerX: number;
67
+ centerY: number;
68
+ };
69
+
70
+ export type GridmapItem = GridmapRect & {
71
+ index: number;
72
+ id: string;
73
+ label: string;
74
+ shortLabel: string;
75
+ meta: Record<string, unknown>;
76
+ layerId: string;
77
+ layerLabel: string;
78
+ layerIndex: number;
79
+ groupId: string;
80
+ groupLabel: string;
81
+ groupIndex: number;
82
+ cellCount: number;
83
+ cells: GridmapCell[];
84
+ };
85
+
86
+ export type GridmapGroup = {
87
+ index: number;
88
+ uid: string;
89
+ id: string;
90
+ label: string;
91
+ meta: Record<string, unknown>;
92
+ layerId: string;
93
+ layerLabel: string;
94
+ layerIndex: number;
95
+ items: GridmapItem[];
96
+ bounds: GridmapRect;
97
+ outline: Array<{ x1: number; y1: number; x2: number; y2: number; betweenGroups: boolean }>;
98
+ };
99
+
100
+ export type GridmapLayer = GridmapRect & {
101
+ index: number;
102
+ id: string;
103
+ label: string;
104
+ meta: Record<string, unknown>;
105
+ groups: GridmapGroup[];
106
+ items: GridmapItem[];
107
+ };
108
+
109
+ export type GridmapModel = {
110
+ data: GridmapData;
111
+ world: GridmapRect & { gutter?: number };
112
+ layout?: string;
113
+ cellSide: number;
114
+ layers: GridmapLayer[];
115
+ groups: GridmapGroup[];
116
+ items: GridmapItem[];
117
+ cells: GridmapCell[];
118
+ };
119
+
120
+ export type GridmapTheme = {
121
+ background: string;
122
+ text: string;
123
+ mutedText: string;
124
+ faintText: string;
125
+ cellLine: string;
126
+ itemLine: string;
127
+ groupLine: string;
128
+ layerLine: string;
129
+ font: string;
130
+ };
131
+
132
+ export type GridmapLayerPainter = (args: {
133
+ ctx: CanvasRenderingContext2D;
134
+ model: GridmapModel;
135
+ state: {
136
+ hover: GridmapCell | null;
137
+ selected: GridmapCell | null;
138
+ focus: Record<string, unknown>;
139
+ };
140
+ camera: { cx: number; cy: number; k: number; free: boolean };
141
+ rect: (rect: GridmapRect) => void;
142
+ toScreenX: (x: number) => number;
143
+ toScreenY: (y: number) => number;
144
+ colourOf: (entity: GridmapCell | GridmapItem, fallback?: string) => string;
145
+ theme: GridmapTheme;
146
+ }) => void;
147
+
148
+ export type GridmapOptions = {
149
+ container: HTMLElement;
150
+ data: GridmapData;
151
+ worlds?: Record<string, GridmapRect & { gutter?: number }>;
152
+ layout?: 'auto' | string;
153
+ colours?: Record<string, string>;
154
+ colourBy?: 'item' | 'group' | 'layer' | 'cell';
155
+ showMarks?: boolean;
156
+ markType?: 'number' | 'dot';
157
+ markOpacity?: number;
158
+ relativeMarkSize?: boolean;
159
+ markMaxSize?: number;
160
+ numberMinPx?: number;
161
+ itemLabels?: 'short' | 'full';
162
+ history?: boolean;
163
+ theme?: Partial<GridmapTheme>;
164
+ labels?: {
165
+ item?: (item: GridmapItem, map: Gridmap) => string;
166
+ tooltip?: (cell: GridmapCell, map: Gridmap) => string;
167
+ };
168
+ onReady?: (map: Gridmap) => void;
169
+ onHoverCell?: (cell: GridmapCell | null, map: Gridmap) => void;
170
+ onSelectCell?: (cell: GridmapCell, map: Gridmap) => void;
171
+ onFocusChange?: (focus: Record<string, unknown>, map: Gridmap) => void;
172
+ onDataChange?: (data: GridmapData, map: Gridmap) => void;
173
+ };
174
+
175
+ export const DEFAULT_WORLDS: Record<string, GridmapRect & { gutter: number }>;
176
+
177
+ export function normaliseGridmapData(data: GridmapData): GridmapData;
178
+ export function buildGridmapModel(data: GridmapData, world?: GridmapRect & { gutter?: number }): GridmapModel;
179
+ export function validateGridmapModel(model: GridmapModel): true;
180
+ export function cellAt(model: GridmapModel, x: number, y: number): GridmapCell | null;
181
+ export function itemsOf(layer: GridmapLayerInput): GridmapItemInput[];
182
+ export function createGridmap(options: GridmapOptions): Gridmap;
183
+
184
+ export class Gridmap {
185
+ constructor(options: GridmapOptions);
186
+ on(type: string, listener: (payload: unknown, map: Gridmap) => void): () => void;
187
+ off(type: string, listener: (payload: unknown, map: Gridmap) => void): void;
188
+ addLayer(layer: GridmapLayerPainter): () => void;
189
+ setData(data: GridmapData): void;
190
+ setColours(colours?: Record<string, string>): void;
191
+ setConfig(config?: Partial<GridmapOptions>): void;
192
+ getModel(): GridmapModel;
193
+ getSelectedCell(): GridmapCell | null;
194
+ focusMap(): boolean;
195
+ focusGroup(idOrGroup: string | GridmapGroup): boolean;
196
+ focusItem(idOrItem: string | GridmapItem): boolean;
197
+ focusCell(idOrCell: string | GridmapCell): boolean;
198
+ selectCell(idOrCell: string | GridmapCell): boolean;
199
+ destroy(): void;
200
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ export {
4
+ DEFAULT_WORLDS,
5
+ buildGridmapModel,
6
+ cellAt,
7
+ itemsOf,
8
+ normaliseGridmapData,
9
+ validateGridmapModel,
10
+ } from './model.js';
11
+
12
+ export { Gridmap, createGridmap } from './gridmap.js';
package/src/model.js ADDED
@@ -0,0 +1,375 @@
1
+ 'use strict';
2
+
3
+ export const DEFAULT_WORLDS = {
4
+ portrait: { x: 0, y: 0, width: 1000, height: 1900, gutter: 30 },
5
+ landscape: { x: 0, y: 0, width: 1600, height: 1000, gutter: 30 },
6
+ };
7
+
8
+ const EPS = 1e-6;
9
+ const near = (a, b) => Math.abs(a - b) < EPS;
10
+ const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
11
+ const cellCount = (item) => item.cells.length;
12
+ const weightSum = (items) => items.reduce((sum, item) => sum + item.n, 0);
13
+
14
+ function assertObject(value, label) {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
16
+ throw new TypeError(`${label} must be an object`);
17
+ }
18
+ }
19
+
20
+ function normaliseCell(cell, index) {
21
+ if (typeof cell === 'string' || typeof cell === 'number') {
22
+ return { id: String(cell), label: String(cell), value: null, meta: {} };
23
+ }
24
+ assertObject(cell, `cell ${index + 1}`);
25
+ const id = cell.id ?? String(index + 1);
26
+ return {
27
+ ...cell,
28
+ id: String(id),
29
+ label: String(cell.label ?? id),
30
+ value: Number.isFinite(cell.value) ? cell.value : null,
31
+ meta: cell.meta ?? {},
32
+ };
33
+ }
34
+
35
+ function normaliseItem(item, index) {
36
+ assertObject(item, `item ${index + 1}`);
37
+ const id = item.id ?? item.key ?? slug(item.label ?? item.name ?? `item-${index + 1}`);
38
+ const cells = item.cells ?? Array.from({ length: item.cellCount ?? item.cellsCount ?? 0 }, (_, i) => i + 1);
39
+ if (!Array.isArray(cells) || cells.length === 0) {
40
+ throw new TypeError(`item ${id} must include at least one cell`);
41
+ }
42
+ return {
43
+ ...item,
44
+ id: String(id),
45
+ label: String(item.label ?? item.name ?? id),
46
+ shortLabel: item.shortLabel ? String(item.shortLabel) : String(item.key ?? id),
47
+ cells: cells.map(normaliseCell),
48
+ meta: item.meta ?? {},
49
+ };
50
+ }
51
+
52
+ function normaliseGroup(group, index) {
53
+ assertObject(group, `group ${index + 1}`);
54
+ const id = group.id ?? slug(group.label ?? group.name ?? `group-${index + 1}`);
55
+ const items = group.items ?? [];
56
+ if (!Array.isArray(items) || items.length === 0) {
57
+ throw new TypeError(`group ${id} must include at least one item`);
58
+ }
59
+ return {
60
+ ...group,
61
+ id: String(id),
62
+ label: String(group.label ?? group.name ?? id),
63
+ items: items.map(normaliseItem),
64
+ meta: group.meta ?? {},
65
+ };
66
+ }
67
+
68
+ function normaliseLayer(layer, index) {
69
+ assertObject(layer, `layer ${index + 1}`);
70
+ const id = layer.id ?? slug(layer.label ?? layer.name ?? `layer-${index + 1}`);
71
+ const groups = layer.groups ?? (Array.isArray(layer.items)
72
+ ? [{ id: 'default', label: String(layer.label ?? layer.name ?? id), items: layer.items }]
73
+ : []);
74
+ if (!Array.isArray(groups) || groups.length === 0) {
75
+ throw new TypeError(`layer ${id} must include at least one group`);
76
+ }
77
+ return {
78
+ ...layer,
79
+ id: String(id),
80
+ label: String(layer.label ?? layer.name ?? id),
81
+ groups: groups.map(normaliseGroup),
82
+ meta: layer.meta ?? {},
83
+ };
84
+ }
85
+
86
+ export function normaliseGridmapData(data) {
87
+ assertObject(data, 'gridmap data');
88
+ if (!Array.isArray(data.layers) || data.layers.length === 0) {
89
+ throw new TypeError('gridmap data must include at least one layer');
90
+ }
91
+ return {
92
+ ...data,
93
+ layers: data.layers.map(normaliseLayer),
94
+ meta: data.meta ?? {},
95
+ };
96
+ }
97
+
98
+ export const itemsOf = (layer) => layer.groups.flatMap((group) =>
99
+ group.items.map((item) => ({ ...item, groupId: group.id, groupLabel: group.label, groupMeta: group.meta })));
100
+
101
+ function rowCounts(n, rows) {
102
+ const base = Math.floor(n / rows);
103
+ const extra = n % rows;
104
+ return Array.from({ length: rows }, (_, i) => base + (i >= rows - extra ? 1 : 0));
105
+ }
106
+
107
+ function gridCost(n, width, height, rows) {
108
+ let sum = 0;
109
+ let worst = 0;
110
+ for (const count of rowCounts(n, rows)) {
111
+ const error = Math.log((width / count) / (height * count / n)) ** 2;
112
+ sum += count * error;
113
+ worst = Math.max(worst, error);
114
+ }
115
+ return sum + 2 * worst;
116
+ }
117
+
118
+ function bestGrid(n, width, height) {
119
+ let best = { rows: 1, cost: Infinity };
120
+ for (let rows = 1; rows <= n; rows += 1) {
121
+ const cost = gridCost(n, width, height, rows);
122
+ if (cost < best.cost) best = { rows, cost };
123
+ }
124
+ return best;
125
+ }
126
+
127
+ function cut(rect, fraction) {
128
+ return rect.width >= rect.height
129
+ ? [
130
+ { x: rect.x, y: rect.y, width: rect.width * fraction, height: rect.height },
131
+ { x: rect.x + rect.width * fraction, y: rect.y, width: rect.width * (1 - fraction), height: rect.height },
132
+ ]
133
+ : [
134
+ { x: rect.x, y: rect.y, width: rect.width, height: rect.height * fraction },
135
+ { x: rect.x, y: rect.y + rect.height * fraction, width: rect.width, height: rect.height * (1 - fraction) },
136
+ ];
137
+ }
138
+
139
+ const SPLIT_CANDIDATES = 3;
140
+
141
+ function partitionItems(items, rect) {
142
+ if (items.length === 1) {
143
+ return { cost: bestGrid(items[0].n, rect.width, rect.height).cost, regions: [{ ...items[0], rect }] };
144
+ }
145
+
146
+ const total = weightSum(items);
147
+ const splits = [];
148
+ for (let k = 1, acc = 0; k < items.length; k += 1) {
149
+ acc += items[k - 1].n;
150
+ splits.push({ k, imbalance: Math.abs(acc - total / 2) });
151
+ }
152
+ splits.sort((a, b) => a.imbalance - b.imbalance || a.k - b.k);
153
+
154
+ let best = null;
155
+ for (const { k } of splits.slice(0, SPLIT_CANDIDATES)) {
156
+ const first = items.slice(0, k);
157
+ const rest = items.slice(k);
158
+ const [aRect, bRect] = cut(rect, weightSum(first) / total);
159
+ const a = partitionItems(first, aRect);
160
+ const b = partitionItems(rest, bRect);
161
+ if (!best || a.cost + b.cost < best.cost) {
162
+ best = { cost: a.cost + b.cost, regions: [...a.regions, ...b.regions] };
163
+ }
164
+ }
165
+ return best;
166
+ }
167
+
168
+ function layoutCells(cells, x, y, width, height) {
169
+ const regions = [];
170
+ let cy = y;
171
+ for (const count of rowCounts(cells.length, bestGrid(cells.length, width, height).rows)) {
172
+ const rowHeight = height * count / cells.length;
173
+ for (let i = 0; i < count; i += 1) {
174
+ regions.push({
175
+ x: x + width * i / count,
176
+ y: cy,
177
+ width: width / count,
178
+ height: rowHeight,
179
+ });
180
+ }
181
+ cy += rowHeight;
182
+ }
183
+ return regions;
184
+ }
185
+
186
+ function boundsOf(rects) {
187
+ const x = Math.min(...rects.map((r) => r.x));
188
+ const y = Math.min(...rects.map((r) => r.y));
189
+ const x2 = Math.max(...rects.map((r) => r.x + r.width));
190
+ const y2 = Math.max(...rects.map((r) => r.y + r.height));
191
+ return { x, y, width: x2 - x, height: y2 - y };
192
+ }
193
+
194
+ function subtractIntervals(lo, hi, cuts) {
195
+ const pieces = [];
196
+ let cur = lo;
197
+ for (const [a, b] of [...cuts].sort((p, q) => p[0] - q[0])) {
198
+ if (b <= cur + EPS) continue;
199
+ if (a > cur + EPS) pieces.push([cur, Math.min(a, hi)]);
200
+ cur = Math.max(cur, b);
201
+ if (cur >= hi - EPS) break;
202
+ }
203
+ if (cur < hi - EPS) pieces.push([cur, hi]);
204
+ return pieces.filter(([a, b]) => b - a > EPS);
205
+ }
206
+
207
+ function regionOutline(rects) {
208
+ const segments = [];
209
+ for (const rect of rects) {
210
+ const left = rect.x;
211
+ const right = rect.x + rect.width;
212
+ const top = rect.y;
213
+ const bottom = rect.y + rect.height;
214
+ const sides = [
215
+ { at: top, lo: left, hi: right, horizontal: true, meets: (other) => other.y + other.height },
216
+ { at: bottom, lo: left, hi: right, horizontal: true, meets: (other) => other.y },
217
+ { at: left, lo: top, hi: bottom, horizontal: false, meets: (other) => other.x + other.width },
218
+ { at: right, lo: top, hi: bottom, horizontal: false, meets: (other) => other.x },
219
+ ];
220
+
221
+ for (const side of sides) {
222
+ const covered = rects
223
+ .filter((other) => other !== rect && near(side.meets(other), side.at))
224
+ .map((other) => (side.horizontal ? [other.x, other.x + other.width] : [other.y, other.y + other.height]));
225
+
226
+ for (const [a, b] of subtractIntervals(side.lo, side.hi, covered)) {
227
+ segments.push(side.horizontal
228
+ ? { x1: a, y1: side.at, x2: b, y2: side.at }
229
+ : { x1: side.at, y1: a, x2: side.at, y2: b });
230
+ }
231
+ }
232
+ }
233
+ return segments;
234
+ }
235
+
236
+ function onPerimeter(segment, rect) {
237
+ return segment.y1 === segment.y2
238
+ ? near(segment.y1, rect.y) || near(segment.y1, rect.y + rect.height)
239
+ : near(segment.x1, rect.x) || near(segment.x1, rect.x + rect.width);
240
+ }
241
+
242
+ export function buildGridmapModel(input, world = DEFAULT_WORLDS.landscape) {
243
+ const data = normaliseGridmapData(input);
244
+ const totalCells = data.layers.reduce((sum, layer) =>
245
+ sum + itemsOf(layer).reduce((itemSum, item) => itemSum + cellCount(item), 0), 0);
246
+ const usableHeight = world.height - world.gutter * (data.layers.length - 1);
247
+
248
+ const model = { data, world: { ...world }, layers: [], groups: [], items: [], cells: [] };
249
+ let layerY = world.y;
250
+
251
+ data.layers.forEach((layerInput, layerIndex) => {
252
+ const sourceItems = itemsOf(layerInput).map((item) => ({ ...item, n: item.cells.length }));
253
+ const layerRect = {
254
+ x: world.x,
255
+ y: layerY,
256
+ width: world.width,
257
+ height: usableHeight * weightSum(sourceItems) / totalCells,
258
+ };
259
+ const layer = {
260
+ index: layerIndex,
261
+ id: layerInput.id,
262
+ label: layerInput.label,
263
+ meta: layerInput.meta,
264
+ ...layerRect,
265
+ groups: [],
266
+ items: [],
267
+ };
268
+ model.layers.push(layer);
269
+
270
+ for (const region of partitionItems(sourceItems, layerRect).regions) {
271
+ const { id, label, shortLabel, cells, groupId, groupLabel, groupMeta, meta, rect } = region;
272
+ const item = {
273
+ index: model.items.length,
274
+ id,
275
+ label,
276
+ shortLabel,
277
+ meta,
278
+ layerId: layer.id,
279
+ layerLabel: layer.label,
280
+ layerIndex: layer.index,
281
+ groupId,
282
+ groupLabel,
283
+ groupIndex: -1,
284
+ cellCount: cells.length,
285
+ ...rect,
286
+ cells: [],
287
+ };
288
+
289
+ layoutCells(cells, rect.x, rect.y, rect.width, rect.height).forEach((cellRect, cellIndex) => {
290
+ const source = cells[cellIndex];
291
+ const cell = {
292
+ index: model.cells.length,
293
+ id: source.id,
294
+ label: source.label,
295
+ value: source.value,
296
+ meta: source.meta,
297
+ layerId: layer.id,
298
+ layerLabel: layer.label,
299
+ layerIndex: layer.index,
300
+ groupId,
301
+ groupLabel,
302
+ groupIndex: -1,
303
+ itemId: item.id,
304
+ itemLabel: item.label,
305
+ itemShortLabel: item.shortLabel,
306
+ itemIndex: item.index,
307
+ ordinal: cellIndex + 1,
308
+ ...cellRect,
309
+ centerX: cellRect.x + cellRect.width / 2,
310
+ centerY: cellRect.y + cellRect.height / 2,
311
+ };
312
+ item.cells.push(cell);
313
+ model.cells.push(cell);
314
+ });
315
+
316
+ layer.items.push(item);
317
+ model.items.push(item);
318
+ }
319
+
320
+ for (const groupInput of layerInput.groups) {
321
+ const items = layer.items.filter((item) => item.groupId === groupInput.id);
322
+ const group = {
323
+ index: model.groups.length,
324
+ uid: `${layer.id}/${groupInput.id}`,
325
+ id: groupInput.id,
326
+ label: groupInput.label,
327
+ meta: groupInput.meta ?? {},
328
+ layerId: layer.id,
329
+ layerLabel: layer.label,
330
+ layerIndex: layer.index,
331
+ items,
332
+ bounds: boundsOf(items),
333
+ };
334
+ group.outline = regionOutline(items).map((segment) => ({
335
+ ...segment,
336
+ betweenGroups: !onPerimeter(segment, layer),
337
+ }));
338
+ for (const item of items) {
339
+ item.groupIndex = group.index;
340
+ for (const cell of item.cells) cell.groupIndex = group.index;
341
+ }
342
+ layer.groups.push(group);
343
+ model.groups.push(group);
344
+ }
345
+
346
+ layerY += layer.height + world.gutter;
347
+ });
348
+
349
+ const firstCell = model.cells[0];
350
+ model.cellSide = firstCell ? Math.sqrt(firstCell.width * firstCell.height) : 1;
351
+ return model;
352
+ }
353
+
354
+ export function cellAt(model, x, y) {
355
+ if (!model) return null;
356
+ for (const item of model.items) {
357
+ if (x < item.x || x >= item.x + item.width || y < item.y || y >= item.y + item.height) continue;
358
+ for (const cell of item.cells) {
359
+ if (x >= cell.x && x < cell.x + cell.width && y >= cell.y && y < cell.y + cell.height) return cell;
360
+ }
361
+ }
362
+ return null;
363
+ }
364
+
365
+ export function validateGridmapModel(model) {
366
+ const itemCells = model.items.reduce((sum, item) => sum + item.cells.length, 0);
367
+ if (itemCells !== model.cells.length) throw new Error('model cell count does not match item cell total');
368
+ if (model.groups.flatMap((group) => group.items).length !== model.items.length) {
369
+ throw new Error('each item must belong to exactly one group');
370
+ }
371
+ if (!model.cells.every((cell) => model.items[cell.itemIndex]?.cells.includes(cell))) {
372
+ throw new Error('each cell must belong to exactly one item');
373
+ }
374
+ return true;
375
+ }