@data-profile/fingerprint 0.0.0-stage → 0.2.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,669 @@
1
+ // temp/src/palette.ts
2
+ var DEFAULT_PALETTE = [
3
+ "#2f6fde",
4
+ "#2bb3e6",
5
+ "#f26b3a",
6
+ "#f5b82e",
7
+ "#14a39a",
8
+ "#7a5af5",
9
+ "#3fae6b",
10
+ "#d9488f",
11
+ "#8c6d46",
12
+ "#5e8c99"
13
+ ];
14
+ var UNKNOWN_COLOR = "#b8bcc4";
15
+
16
+ // temp/src/hash.ts
17
+ function stableHash(value) {
18
+ let hash = 2166136261;
19
+ for (let i = 0;i < value.length; i++)
20
+ hash = Math.imul(hash ^ value.charCodeAt(i), 16777619);
21
+ return hash >>> 0;
22
+ }
23
+ var unitHash = (key) => stableHash(key) / 2 ** 32;
24
+ var compareIds = (a, b) => a < b ? -1 : a > b ? 1 : 0;
25
+
26
+ // temp/src/form.ts
27
+ var clamp = (value, low, high) => Math.min(high, Math.max(low, value));
28
+ var quantise = (value, step = 0.2) => Math.round(value / step) * step;
29
+ function formFromSeed(seed) {
30
+ const h = (key) => unitHash(`${seed}:${key}`);
31
+ return {
32
+ pattern: "loop",
33
+ hand: h("hand") < 0.5 ? -1 : 1,
34
+ coreX: quantise(h("x") * 2 - 1) * 0.5,
35
+ coreY: quantise(h("y") * 2 - 1) * 0.5,
36
+ spread: quantise(h("spread")),
37
+ tilt: Math.round((h("tilt") - 0.5) * 12)
38
+ };
39
+ }
40
+ function resolveForm(seed, shape = {}) {
41
+ const resolved = { ...formFromSeed(seed), ...shape };
42
+ return {
43
+ ...resolved,
44
+ coreX: clamp(resolved.coreX, -1, 1),
45
+ coreY: clamp(resolved.coreY, -1, 1),
46
+ spread: clamp(resolved.spread, 0, 1),
47
+ tilt: clamp(resolved.tilt, -10, 10)
48
+ };
49
+ }
50
+ function lean(items, dimension) {
51
+ if (!dimension || dimension.categories.length < 2)
52
+ return 0.5;
53
+ const index = new Map(dimension.categories.map((c, i) => [c.id, i / (dimension.categories.length - 1)]));
54
+ let sum = 0;
55
+ let n = 0;
56
+ for (const item of items) {
57
+ const at = index.get(item.values[dimension.id] ?? "");
58
+ if (at !== undefined) {
59
+ sum += at;
60
+ n += 1;
61
+ }
62
+ }
63
+ return n ? sum / n : 0.5;
64
+ }
65
+ function formFromItems(items, dimensions, seed = "fingerprint") {
66
+ const [first, second, third] = dimensions;
67
+ let top = 0;
68
+ if (first) {
69
+ const counts = new Map;
70
+ for (const item of items) {
71
+ const value = item.values[first.id];
72
+ if (value != null)
73
+ counts.set(value, (counts.get(value) ?? 0) + 1);
74
+ }
75
+ top = items.length ? Math.max(0, ...counts.values()) / items.length : 0;
76
+ }
77
+ const total = dimensions.reduce((sum, d) => sum + d.categories.length, 0);
78
+ const used = dimensions.reduce((sum, d) => sum + d.categories.filter((c) => items.some((item) => item.values[d.id] === c.id)).length, 0);
79
+ const across = lean(items, second);
80
+ return resolveForm(seed, {
81
+ pattern: items.length < 12 ? "arch" : top >= 0.4 ? "loop" : "whorl",
82
+ hand: across < 0.5 ? -1 : 1,
83
+ coreX: quantise((across - 0.5) * 2),
84
+ coreY: quantise((lean(items, third) - 0.5) * 2),
85
+ spread: total ? quantise(used / total) : 0.5
86
+ });
87
+ }
88
+
89
+ // temp/src/ridges.ts
90
+ var FRAME = { width: 400, height: 520, cx: 200, cy: 260, rx: 180, ry: 240 };
91
+ var DEG = Math.PI / 180;
92
+ var outlineRadius = (x, y) => Math.hypot((x - FRAME.cx) / FRAME.rx, (y - FRAME.cy) / FRAME.ry);
93
+ var smoothstep = (a, b, x) => {
94
+ const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
95
+ return t * t * (3 - 2 * t);
96
+ };
97
+ function singularities(shape) {
98
+ const { rx, ry, cx, cy } = FRAME;
99
+ const centre = [cx + shape.coreX * 0.12 * rx, cy - 0.1 * ry + shape.coreY * 0.1 * ry];
100
+ const [x, y] = centre;
101
+ if (shape.pattern === "arch")
102
+ return { centre, cores: [], deltas: [] };
103
+ if (shape.pattern === "loop")
104
+ return {
105
+ centre,
106
+ cores: [centre],
107
+ deltas: [
108
+ [x + shape.hand * (0.4 + 0.25 * shape.spread) * rx, y + (0.38 + 0.12 * shape.spread) * ry]
109
+ ]
110
+ };
111
+ const twist = 0.06 * rx;
112
+ return {
113
+ centre,
114
+ cores: [
115
+ [x - twist, y - shape.hand * 0.025 * ry],
116
+ [x + twist, y + shape.hand * 0.025 * ry]
117
+ ],
118
+ deltas: [
119
+ [x - (0.5 + 0.2 * shape.spread) * rx, y + 0.42 * ry],
120
+ [x + (0.5 + 0.2 * shape.spread) * rx, y + 0.42 * ry]
121
+ ]
122
+ };
123
+ }
124
+ function orientation(x, y, shape, s) {
125
+ const tilt = shape.tilt * DEG;
126
+ let theta;
127
+ if (shape.pattern === "arch") {
128
+ const width = (0.35 + 0.25 * shape.spread) * FRAME.rx;
129
+ const u = (x - s.centre[0]) / width;
130
+ const height = 0.3 * FRAME.ry * Math.max(0, 1 - Math.abs(y - s.centre[1]) / FRAME.ry);
131
+ theta = tilt + Math.atan(2 * height * u / width * Math.exp(-u * u));
132
+ } else {
133
+ let sum = 0;
134
+ for (const [px, py] of s.cores)
135
+ sum += Math.atan2(y - py, x - px);
136
+ for (const [px, py] of s.deltas)
137
+ sum -= Math.atan2(y - py, x - px);
138
+ theta = tilt + sum / 2;
139
+ }
140
+ const r = outlineRadius(x, y);
141
+ const over = smoothstep(FRAME.cy + 0.25 * FRAME.ry, FRAME.cy - 0.25 * FRAME.ry, y);
142
+ const weight = smoothstep(0.68, 1, r) * (0.4 + 0.6 * over);
143
+ if (weight === 0)
144
+ return theta;
145
+ const tangent = Math.atan2((x - FRAME.cx) / FRAME.rx ** 2, -(y - FRAME.cy) / FRAME.ry ** 2);
146
+ const vx = (1 - weight) * Math.cos(2 * theta) + weight * Math.cos(2 * tangent);
147
+ const vy = (1 - weight) * Math.sin(2 * theta) + weight * Math.sin(2 * tangent);
148
+ return Math.atan2(vy, vx) / 2;
149
+ }
150
+
151
+ class PointGrid {
152
+ size;
153
+ cells = new Map;
154
+ constructor(size) {
155
+ this.size = size;
156
+ }
157
+ key = (gx, gy) => gx * 4096 + gy;
158
+ add(x, y, line, index) {
159
+ const k = this.key(Math.floor(x / this.size), Math.floor(y / this.size));
160
+ const cell = this.cells.get(k);
161
+ if (cell)
162
+ cell.push({ x, y, line, index });
163
+ else
164
+ this.cells.set(k, [{ x, y, line, index }]);
165
+ return k;
166
+ }
167
+ removeLine(line, keys) {
168
+ for (const k of new Set(keys)) {
169
+ const kept = (this.cells.get(k) ?? []).filter((p) => p.line !== line);
170
+ if (kept.length)
171
+ this.cells.set(k, kept);
172
+ else
173
+ this.cells.delete(k);
174
+ }
175
+ }
176
+ near(x, y, distance, counts) {
177
+ const gx = Math.floor(x / this.size);
178
+ const gy = Math.floor(y / this.size);
179
+ for (let dx = -1;dx <= 1; dx++)
180
+ for (let dy = -1;dy <= 1; dy++)
181
+ for (const p of this.cells.get(this.key(gx + dx, gy + dy)) ?? [])
182
+ if ((p.x - x) ** 2 + (p.y - y) ** 2 < distance ** 2 && counts(p))
183
+ return true;
184
+ return false;
185
+ }
186
+ }
187
+ var OUTLINE_LIMIT = 0.985;
188
+ function ridges(shape, spacing) {
189
+ const s = singularities(shape);
190
+ const step = spacing * 0.3;
191
+ const selfGap = Math.ceil(3 * spacing / step);
192
+ const minLength = Math.ceil(2 * spacing / step);
193
+ const maxSteps = Math.ceil(4 * Math.PI * (FRAME.rx + FRAME.ry) / step);
194
+ const grid = new PointGrid(spacing);
195
+ const lines = [];
196
+ const singular = [...s.cores, ...s.deltas];
197
+ const free = (x, y, line, index) => outlineRadius(x, y) <= OUTLINE_LIMIT && singular.every(([px, py]) => Math.hypot(x - px, y - py) > spacing * 0.6) && !grid.near(x, y, spacing, (p) => p.line !== line || Math.abs(p.index - index) > selfGap);
198
+ const direction = (x, y, previous) => {
199
+ const theta = orientation(x, y, shape, s);
200
+ const v = [Math.cos(theta), Math.sin(theta)];
201
+ return v[0] * previous[0] + v[1] * previous[1] < 0 ? [-v[0], -v[1]] : v;
202
+ };
203
+ const trace = (seed) => {
204
+ const line = lines.length;
205
+ const theta = orientation(seed[0], seed[1], shape, s);
206
+ const keys = [grid.add(seed[0], seed[1], line, 0)];
207
+ const halves = [1, -1].map((sign) => {
208
+ const points2 = [];
209
+ let p = seed;
210
+ let previous = [sign * Math.cos(theta), sign * Math.sin(theta)];
211
+ for (let i = 1;i <= maxSteps; i++) {
212
+ const v1 = direction(p[0], p[1], previous);
213
+ const v2 = direction(p[0] + v1[0] * step / 2, p[1] + v1[1] * step / 2, v1);
214
+ if (v2[0] * previous[0] + v2[1] * previous[1] < 0.8)
215
+ break;
216
+ const q = [p[0] + v2[0] * step, p[1] + v2[1] * step];
217
+ if (!free(q[0], q[1], line, sign * i))
218
+ break;
219
+ keys.push(grid.add(q[0], q[1], line, sign * i));
220
+ points2.push(q);
221
+ p = q;
222
+ previous = v2;
223
+ }
224
+ return points2;
225
+ });
226
+ const points = [...halves[1].reverse(), seed, ...halves[0]];
227
+ if (points.length < minLength) {
228
+ grid.removeLine(line, keys);
229
+ return null;
230
+ }
231
+ return points;
232
+ };
233
+ const queue = [];
234
+ const seedFrom = (points) => {
235
+ const every = Math.max(1, Math.round(spacing / step / 2));
236
+ for (let i = 1;i < points.length - 1; i += every) {
237
+ const [ax, ay] = points[i - 1];
238
+ const [bx, by] = points[i + 1];
239
+ const length = Math.hypot(bx - ax, by - ay) || 1;
240
+ const [nx, ny] = [-(by - ay) / length, (bx - ax) / length];
241
+ const [x, y] = points[i];
242
+ queue.push([x + nx * spacing, y + ny * spacing], [x - nx * spacing, y - ny * spacing]);
243
+ }
244
+ };
245
+ const attempt = (seed) => {
246
+ if (!free(seed[0], seed[1], -1, 0))
247
+ return;
248
+ const points = trace(seed);
249
+ if (!points)
250
+ return;
251
+ lines.push(points);
252
+ seedFrom(points);
253
+ };
254
+ let next = 0;
255
+ const drain = () => {
256
+ while (next < queue.length)
257
+ attempt(queue[next++]);
258
+ };
259
+ queue.push([s.centre[0], s.centre[1] - spacing]);
260
+ drain();
261
+ const sweep = [];
262
+ for (let x = FRAME.cx - FRAME.rx;x <= FRAME.cx + FRAME.rx; x += spacing / 2)
263
+ for (let y = FRAME.cy - FRAME.ry;y <= FRAME.cy + FRAME.ry; y += spacing / 2)
264
+ if (outlineRadius(x, y) <= OUTLINE_LIMIT)
265
+ sweep.push([x, y]);
266
+ const distance = ([x, y]) => Math.hypot(x - s.centre[0], y - s.centre[1]);
267
+ sweep.sort((a, b) => distance(a) - distance(b) || a[0] - b[0] || a[1] - b[1]);
268
+ for (const seed of sweep) {
269
+ attempt(seed);
270
+ drain();
271
+ }
272
+ return orderFromCore(lines, s.centre);
273
+ }
274
+ function orderFromCore(lines, [cx, cy]) {
275
+ const reach = (line) => Math.min(...line.map(([x, y]) => Math.hypot(x - cx, y - cy)));
276
+ const swept = (line) => {
277
+ let total = 0;
278
+ for (let i = 1;i < line.length; i++) {
279
+ let d = Math.atan2(line[i][1] - cy, line[i][0] - cx) - Math.atan2(line[i - 1][1] - cy, line[i - 1][0] - cx);
280
+ if (d > Math.PI)
281
+ d -= 2 * Math.PI;
282
+ if (d < -Math.PI)
283
+ d += 2 * Math.PI;
284
+ total += d;
285
+ }
286
+ return total;
287
+ };
288
+ return lines.map((line) => ({ line: swept(line) < 0 ? [...line].reverse() : line, reach: reach(line) })).sort((a, b) => a.reach - b.reach || a.line[0][0] - b.line[0][0] || a.line[0][1] - b.line[0][1]).map(({ line }) => line);
289
+ }
290
+
291
+ // temp/src/slots.ts
292
+ function polylineLength(points) {
293
+ let length = 0;
294
+ for (let i = 1;i < points.length; i++)
295
+ length += Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]);
296
+ return length;
297
+ }
298
+ function allocate(lengths, count) {
299
+ const total = lengths.reduce((sum, length) => sum + length, 0);
300
+ if (total === 0 || count === 0)
301
+ return lengths.map(() => 0);
302
+ const average = total / count;
303
+ const counts = lengths.map((length) => Math.floor(length / average));
304
+ for (let left = count - counts.reduce((sum, n) => sum + n, 0);left > 0; left--) {
305
+ let best = 0;
306
+ for (let i = 1;i < lengths.length; i++)
307
+ if (lengths[i] / (counts[i] + 1) > lengths[best] / (counts[best] + 1))
308
+ best = i;
309
+ counts[best] += 1;
310
+ }
311
+ return counts;
312
+ }
313
+ function pointAt(line, at) {
314
+ let walked = 0;
315
+ for (let i = 1;i < line.length; i++) {
316
+ const [x0, y0] = line[i - 1];
317
+ const [x1, y1] = line[i];
318
+ const segment = Math.hypot(x1 - x0, y1 - y0);
319
+ if (at <= walked + segment || i === line.length - 1) {
320
+ const f = segment === 0 ? 0 : Math.min(1, (at - walked) / segment);
321
+ return {
322
+ x: x0 + (x1 - x0) * f,
323
+ y: y0 + (y1 - y0) * f,
324
+ rotation: Math.atan2(y1 - y0, x1 - x0) * 180 / Math.PI
325
+ };
326
+ }
327
+ walked += segment;
328
+ }
329
+ const [x, y] = line[0];
330
+ return { x, y, rotation: 0 };
331
+ }
332
+ function slotsAlong(lines, count) {
333
+ const lengths = lines.map(polylineLength);
334
+ const counts = allocate(lengths, count);
335
+ const slots = [];
336
+ let pitch = Infinity;
337
+ lines.forEach((line, k) => {
338
+ const n = counts[k];
339
+ if (n === 0)
340
+ return;
341
+ const step = lengths[k] / n;
342
+ if (n > 1)
343
+ pitch = Math.min(pitch, step);
344
+ for (let i = 0;i < n; i++)
345
+ slots.push(pointAt(line, (i + 0.5) * step));
346
+ });
347
+ return { slots, pitch };
348
+ }
349
+
350
+ // temp/src/layout.ts
351
+ var UNKNOWN_CATEGORY_ID = "~unknown";
352
+ var DEFAULTS = { seed: "fingerprint", maxMarks: 600, minRidges: 6 };
353
+ var MARK_FILL = 0.85;
354
+
355
+ class FingerprintDataError extends Error {
356
+ name = "FingerprintDataError";
357
+ }
358
+ var positive = (value) => value !== undefined && Number.isFinite(value) && value >= 1 ? Math.floor(value) : undefined;
359
+ var DISTINCT = { color: 10, shape: 5 };
360
+ var prepared = new WeakMap;
361
+ function prepare(items) {
362
+ const hit = prepared.get(items);
363
+ if (hit)
364
+ return hit;
365
+ const seen = new Set;
366
+ for (const item of items) {
367
+ if (seen.has(item.id))
368
+ throw new FingerprintDataError(`Duplicate item id "${item.id}"`);
369
+ seen.add(item.id);
370
+ const weight = item.weight ?? 1;
371
+ if (!Number.isFinite(weight) || weight <= 0)
372
+ throw new FingerprintDataError(`Item "${item.id}" has weight ${weight}; weights must be positive`);
373
+ }
374
+ const byId = Uint32Array.from(items.keys()).sort((a, b) => compareIds(items[a].id, items[b].id));
375
+ const idRank = new Uint32Array(items.length);
376
+ byId.forEach((index, rank) => idRank[index] = rank);
377
+ const result = { idRank, ranks: new WeakMap };
378
+ prepared.set(items, result);
379
+ return result;
380
+ }
381
+ function ranksIn(items, ready, d) {
382
+ const hit = ready.ranks.get(d);
383
+ if (hit)
384
+ return hit;
385
+ const index = new Map(d.categories.map((c, i) => [c.id, i]));
386
+ const unknown = d.categories.length;
387
+ const ranks = new Int32Array(items.length);
388
+ items.forEach((item, i) => {
389
+ const value = item.values[d.id];
390
+ ranks[i] = (value != null ? index.get(value) : undefined) ?? unknown;
391
+ });
392
+ ready.ranks.set(d, ranks);
393
+ return ranks;
394
+ }
395
+ function validateDimension(dimension) {
396
+ const categories = new Set;
397
+ for (const category of dimension.categories) {
398
+ if (category.id === UNKNOWN_CATEGORY_ID)
399
+ throw new FingerprintDataError(`Category id "${UNKNOWN_CATEGORY_ID}" is reserved`);
400
+ if (categories.has(category.id))
401
+ throw new FingerprintDataError(`Duplicate category id "${category.id}" in "${dimension.id}"`);
402
+ categories.add(category.id);
403
+ }
404
+ }
405
+ function resolveEncoding(dimensions, encoding) {
406
+ const arrange = encoding?.arrange ?? dimensions[0]?.id;
407
+ if (arrange === undefined)
408
+ throw new FingerprintDataError("At least one dimension is needed");
409
+ const resolved = {
410
+ arrange,
411
+ color: encoding?.color === undefined ? arrange : encoding.color,
412
+ shape: encoding?.shape === undefined ? arrange : encoding.shape,
413
+ lightness: encoding?.lightness ?? null
414
+ };
415
+ for (const [channel, id] of Object.entries(resolved)) {
416
+ if (id === null)
417
+ continue;
418
+ const dimension = dimensions.find((d) => d.id === id);
419
+ if (!dimension)
420
+ throw new FingerprintDataError(`The ${channel} encoding names "${id}", which is not a dimension`);
421
+ if (channel === "lightness" && !dimension.ordered)
422
+ throw new FingerprintDataError(`"${id}" drives lightness but is not ordered; lighter and darker would read as less and more`);
423
+ }
424
+ return resolved;
425
+ }
426
+ var ridgeCache = new Map;
427
+ function cachedRidges(form, spacing) {
428
+ const key = `${JSON.stringify(form)}:${spacing}`;
429
+ const hit = ridgeCache.get(key);
430
+ if (hit)
431
+ return hit;
432
+ const traced = ridges(form, spacing);
433
+ ridgeCache.set(key, traced);
434
+ if (ridgeCache.size > 8)
435
+ ridgeCache.delete(ridgeCache.keys().next().value);
436
+ return traced;
437
+ }
438
+ function layoutFingerprint(input) {
439
+ const { items, dimensions, encoding } = input;
440
+ const seed = input.seed ?? DEFAULTS.seed;
441
+ const maxMarks = positive(input.maxMarks) ?? DEFAULTS.maxMarks;
442
+ const minRidges = positive(input.minRidges) ?? DEFAULTS.minRidges;
443
+ const form = resolveForm(seed, input.form);
444
+ const ready = prepare(items);
445
+ const encoded = resolveEncoding(dimensions, encoding);
446
+ const byId = new Map(dimensions.map((d) => [d.id, d]));
447
+ const used = [...new Set([encoded.color, encoded.shape, encoded.lightness, encoded.arrange])].filter((id) => id !== null).map((id) => byId.get(id));
448
+ used.forEach(validateDimension);
449
+ const priority = [...new Set([encoded.arrange, encoded.color, encoded.shape, encoded.lightness])].filter((id) => id !== null).map((id) => byId.get(id));
450
+ const ranks = new Map(used.map((d) => [d.id, ranksIn(items, ready, d)]));
451
+ const categoryAt = (d, rank) => d.categories[rank]?.id ?? UNKNOWN_CATEGORY_ID;
452
+ const groups = used.map((d) => {
453
+ const channels = ["color", "shape", "lightness", "arrange"].filter((c) => encoded[c] === d.id);
454
+ const drives = (c) => channels.includes(c);
455
+ const entry = (id, label, i, color, href) => ({
456
+ id,
457
+ label,
458
+ color: drives("color") ? i === null ? UNKNOWN_COLOR : color ?? DEFAULT_PALETTE[i % DEFAULT_PALETTE.length] : "currentColor",
459
+ shape: drives("shape") ? i : 0,
460
+ lightness: drives("lightness") && i !== null ? d.categories.length > 1 ? i / (d.categories.length - 1) : 1 : null,
461
+ href,
462
+ unknown: i === null,
463
+ count: 0,
464
+ weight: 0
465
+ });
466
+ return {
467
+ dimensionId: d.id,
468
+ label: d.label,
469
+ channels,
470
+ entries: [
471
+ ...d.categories.map((c, i) => entry(c.id, c.label, i, c.color, c.href)),
472
+ entry(UNKNOWN_CATEGORY_ID, d.unknownLabel ?? "Unknown", null)
473
+ ]
474
+ };
475
+ });
476
+ const groupOf = new Map(groups.map((g) => [g.dimensionId, g]));
477
+ const entryOf = (dimensionId, categoryId) => {
478
+ const d = byId.get(dimensionId);
479
+ const rank = d.categories.findIndex((c) => c.id === categoryId);
480
+ return groupOf.get(dimensionId).entries[rank === -1 ? d.categories.length : rank];
481
+ };
482
+ for (const d of used) {
483
+ const entries = groupOf.get(d.id).entries;
484
+ const r = ranks.get(d.id);
485
+ for (let i = 0;i < items.length; i++) {
486
+ const entry = entries[r[i]];
487
+ entry.count += 1;
488
+ entry.weight += items[i].weight ?? 1;
489
+ }
490
+ }
491
+ const n = items.length;
492
+ const comboKeys = new Float64Array(n);
493
+ const priorityRanks = priority.map((d) => ranks.get(d.id));
494
+ const radix = priority.map((d) => d.categories.length + 1);
495
+ for (let i = 0;i < n; i++) {
496
+ let key = 0;
497
+ for (let k = 0;k < priority.length; k++)
498
+ key = key * radix[k] + priorityRanks[k][i];
499
+ comboKeys[i] = key;
500
+ }
501
+ const order = Uint32Array.from(items.keys()).sort((a, b) => comboKeys[a] - comboKeys[b] || ready.idRank[a] - ready.idRank[b]);
502
+ const itemsPerMark = Math.max(1, Math.ceil(n / maxMarks));
503
+ const marksOf = [];
504
+ for (let k = 0;k < n; k++) {
505
+ const item = items[order[k]];
506
+ const last = marksOf.at(-1);
507
+ if (last && comboKeys[order[k]] === comboKeys[order[k - 1]] && last.members.length < itemsPerMark) {
508
+ last.members.push(item);
509
+ continue;
510
+ }
511
+ const categories = Object.fromEntries(used.map((d) => [d.id, categoryAt(d, ranks.get(d.id)[order[k]])]));
512
+ marksOf.push({ key: used.map((d) => categories[d.id]).join("|"), categories, members: [item] });
513
+ }
514
+ const marks = Math.max(1, marksOf.length);
515
+ const widest = FRAME.rx / minRidges;
516
+ let spacing = Math.min(widest, Math.sqrt(Math.PI * FRAME.rx * FRAME.ry * 0.85 / marks));
517
+ let lines = cachedRidges(form, spacing);
518
+ if (marksOf.length > 0) {
519
+ const traced = lines.reduce((sum, line) => sum + polylineLength(line), 0);
520
+ const corrected = Math.min(widest, Math.sqrt(traced * spacing / marks));
521
+ if (Math.abs(corrected - spacing) / spacing > 0.03) {
522
+ spacing = corrected;
523
+ lines = cachedRidges(form, spacing);
524
+ }
525
+ }
526
+ const { slots, pitch } = slotsAlong(lines, marksOf.length);
527
+ const footprint = Math.min(pitch, spacing) * MARK_FILL;
528
+ const weights = marksOf.map((mark) => mark.members.reduce((sum, item) => sum + (item.weight ?? 1), 0));
529
+ const heaviest = Math.max(0, ...weights);
530
+ const perKey = new Map;
531
+ const nodes = marksOf.map((mark, i) => {
532
+ const n2 = perKey.get(mark.key) ?? 0;
533
+ perKey.set(mark.key, n2 + 1);
534
+ const slot = slots[i];
535
+ const property = (channel) => {
536
+ const id = encoded[channel];
537
+ return id === null ? null : entryOf(id, mark.categories[id]);
538
+ };
539
+ return {
540
+ id: itemsPerMark === 1 ? mark.members[0].id : `${mark.key}#${n2}`,
541
+ itemIds: mark.members.map((item) => item.id),
542
+ categories: mark.categories,
543
+ x: slot.x,
544
+ y: slot.y,
545
+ rotation: slot.rotation,
546
+ size: footprint * Math.sqrt(weights[i] / heaviest),
547
+ weight: weights[i],
548
+ color: property("color")?.color ?? "currentColor",
549
+ shape: encoded.shape === null ? 0 : property("shape").shape,
550
+ lightness: property("lightness")?.lightness ?? null
551
+ };
552
+ });
553
+ for (const group of groups)
554
+ group.entries = group.entries.filter((e) => !e.unknown || e.count > 0);
555
+ const warnings = [];
556
+ for (const channel of ["color", "shape"]) {
557
+ const id = encoded[channel];
558
+ if (id === null)
559
+ continue;
560
+ const shown = groupOf.get(id).entries.filter((e) => !e.unknown && e.count > 0).length;
561
+ if (shown > DISTINCT[channel])
562
+ warnings.push(`${channel === "color" ? "Colour" : "Shape"} shows ${shown} categories of "${byId.get(id).label}"; beyond ${DISTINCT[channel]} they repeat, so only the legend tells them apart.`);
563
+ }
564
+ return {
565
+ width: FRAME.width,
566
+ height: FRAME.height,
567
+ encoding: encoded,
568
+ nodes,
569
+ ridges: lines,
570
+ form,
571
+ ridgeSpacing: spacing,
572
+ legends: groups,
573
+ warnings,
574
+ itemsPerMark,
575
+ itemCount: items.length
576
+ };
577
+ }
578
+ function emptyLayout() {
579
+ return {
580
+ width: FRAME.width,
581
+ height: FRAME.height,
582
+ encoding: { arrange: "", color: null, shape: null, lightness: null },
583
+ nodes: [],
584
+ ridges: [],
585
+ form: resolveForm(DEFAULTS.seed),
586
+ ridgeSpacing: FRAME.rx / DEFAULTS.minRidges,
587
+ legends: [],
588
+ warnings: [],
589
+ itemsPerMark: 1,
590
+ itemCount: 0
591
+ };
592
+ }
593
+
594
+ // temp/src/opacity.ts
595
+ var markOpacity = (node) => node.lightness === null ? 1 : 0.3 + 0.7 * node.lightness;
596
+ function ridgeOpacity(itemCount, style = {}) {
597
+ const { show = "auto", fadeFrom = 60, fadeTo = 160, opacity = 0.22 } = style;
598
+ if (show === "never")
599
+ return 0;
600
+ if (show === "always" || itemCount <= fadeFrom)
601
+ return opacity;
602
+ if (itemCount >= fadeTo)
603
+ return 0;
604
+ return opacity * (1 - (itemCount - fadeFrom) / (fadeTo - fadeFrom));
605
+ }
606
+
607
+ // temp/src/callouts.ts
608
+ var CALLOUT_GUTTER = 190;
609
+ var ANCHOR_CLEARANCE = 28;
610
+ var CALLOUT_HEIGHT = 96;
611
+ function placeCallouts(layout, annotations) {
612
+ const taken = [];
613
+ const anchored = annotations.flatMap((annotation) => {
614
+ const wanted = new Set(annotation.categoryIds);
615
+ const dimensionId = annotation.dimensionId ?? layout.encoding.arrange;
616
+ const marks = layout.nodes.filter((node) => wanted.has(node.categories[dimensionId] ?? ""));
617
+ if (marks.length === 0)
618
+ return [];
619
+ const cx = marks.reduce((sum, node) => sum + node.x, 0) / marks.length;
620
+ const cy = marks.reduce((sum, node) => sum + node.y, 0) / marks.length;
621
+ const byCloseness = [...marks].sort((a, b) => Math.hypot(a.x - cx, a.y - cy) - Math.hypot(b.x - cx, b.y - cy));
622
+ const clear = (node) => taken.every((t) => Math.hypot(node.x - t.x, node.y - t.y) >= ANCHOR_CLEARANCE);
623
+ const anchor = byCloseness.find(clear) ?? byCloseness[0];
624
+ taken.push(anchor);
625
+ const side = anchor.x < layout.width / 2 ? "left" : "right";
626
+ return [{ annotation, anchor: { x: anchor.x, y: anchor.y }, side }];
627
+ });
628
+ const placed = [];
629
+ for (const side of ["left", "right"]) {
630
+ const column = anchored.filter((c) => c.side === side).sort((a, b) => a.anchor.y - b.anchor.y);
631
+ const tops = [];
632
+ for (const callout of column)
633
+ tops.push(Math.max(callout.anchor.y - CALLOUT_HEIGHT / 3, (tops.at(-1) ?? -Infinity) + CALLOUT_HEIGHT));
634
+ const overflow = Math.max(0, (tops.at(-1) ?? 0) + CALLOUT_HEIGHT - layout.height);
635
+ column.forEach((callout, i) => placed.push({ ...callout, labelY: Math.max(0, tops[i] - overflow), number: 0 }));
636
+ }
637
+ return placed.map((callout, i) => ({ ...callout, number: i + 1 }));
638
+ }
639
+
640
+ // temp/src/describe.ts
641
+ var PROPERTY = {
642
+ color: "colour",
643
+ shape: "shape",
644
+ lightness: "lightness"
645
+ };
646
+ function describeLayout(layout, unit = "items") {
647
+ if (layout.itemCount === 0)
648
+ return `No ${unit} yet.`;
649
+ const arrange = layout.legends.find((group) => group.dimensionId === layout.encoding.arrange);
650
+ const ranked = arrange.entries.filter((entry) => entry.count > 0).sort((a, b) => b.count - a.count);
651
+ const named = ranked.slice(0, 3).map((entry) => `${entry.label} ${entry.count.toLocaleString("en-GB")}`);
652
+ const rest = ranked.length - named.length;
653
+ const parts = [
654
+ `${layout.itemCount.toLocaleString("en-GB")} ${unit} by ${arrange.label.toLowerCase()}: ${named.join(", ")}${rest > 0 ? `, and ${rest} more ${rest === 1 ? "category" : "categories"}` : ""}.`
655
+ ];
656
+ if (layout.legends.length > 1) {
657
+ const shown = layout.legends.flatMap((group) => group.channels.filter((channel) => channel !== "arrange").map((channel) => channel === "lightness" ? `lightness shows ${group.label.toLowerCase()} (fainter is earlier)` : `${PROPERTY[channel]} shows ${group.label.toLowerCase()}`));
658
+ if (shown.length)
659
+ parts.push(`${shown.join(", ").replace(/^./, (c) => c.toUpperCase())}.`);
660
+ }
661
+ if (layout.itemsPerMark > 1)
662
+ parts.push(`Each mark stands for up to ${layout.itemsPerMark} ${unit}.`);
663
+ return parts.join(" ");
664
+ }
665
+
666
+ export { DEFAULT_PALETTE, UNKNOWN_COLOR, formFromItems, UNKNOWN_CATEGORY_ID, FingerprintDataError, layoutFingerprint, emptyLayout, markOpacity, ridgeOpacity, CALLOUT_GUTTER, CALLOUT_HEIGHT, placeCallouts, describeLayout };
667
+
668
+ //# debugId=C8FF8090BDB0F57164756E2164756E21
669
+ //# sourceMappingURL=chunk-eygy4k4w.js.map