@mark1russell7/packing 0.1.3 → 0.1.5

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/src/index.ts CHANGED
@@ -7,6 +7,24 @@ export { packFree, aspectError, type FreeWidthOptions, type FreeWidthResult, typ
7
7
  export { compareRects, SORT_ORDERS, type SortOrder } from "./order.ts";
8
8
  export { pushCascade, type PushOptions } from "./push.ts";
9
9
  export { packingMetrics, type MetricsOptions, type PackingMetrics } from "./metrics.ts";
10
+ export {
11
+ MEASURE_SPECS,
12
+ isDeterministic,
13
+ measure,
14
+ measureChange,
15
+ medianRatio,
16
+ pairedRatio,
17
+ quantile,
18
+ roundMeasures,
19
+ tailShare,
20
+ type ChangeMeasures,
21
+ type Direction,
22
+ type LayoutMeasures,
23
+ type MeasureFamily,
24
+ type MeasureId,
25
+ type MeasureOptions,
26
+ type MeasureSpec,
27
+ } from "./measures.ts";
10
28
  export {
11
29
  DEFAULT_TOLERANCE,
12
30
  EPS,
@@ -48,3 +66,37 @@ export { logSweep, push, rows, shelf, skyline, type LayoutStrategyOptions, type
48
66
  export { fromRects, toRects } from "./convert.ts";
49
67
  export { packWith, strategies, strategy } from "./registry.ts";
50
68
  export { checkHard, type HardBreaks } from "./check.ts";
69
+ export {
70
+ canonicalJson,
71
+ countCell,
72
+ fnv1a32,
73
+ hashInput,
74
+ round3,
75
+ toPackInput,
76
+ type CorpusCell,
77
+ type CountScale,
78
+ type Edge,
79
+ type GeneratorParams,
80
+ type GeneratorStamp,
81
+ type LayoutInput,
82
+ type ObstacleLayout,
83
+ type SizeDistribution,
84
+ } from "./layout-input.ts";
85
+ export {
86
+ cards,
87
+ dashboard,
88
+ edgeCases,
89
+ gallery,
90
+ generator,
91
+ generators,
92
+ rectGrid,
93
+ rectScattered,
94
+ wordCloud,
95
+ type CorpusGenerator,
96
+ type EdgeCase,
97
+ type EdgeCaseParams,
98
+ type RectGridParams,
99
+ type RectScatteredParams,
100
+ type UiParams,
101
+ } from "./generators.ts";
102
+ export { corpus, type Corpus, type CorpusEntry, type CorpusManifest, type CorpusSpec } from "./corpus.ts";
@@ -0,0 +1,120 @@
1
+ // The input file `layout-input/1` of `docs/FORMAT.md`: its types, its numbers, its canonical form and its hash.
2
+
3
+ import type { Container, Item, Obstacle, PackInput } from "./contract.ts";
4
+ import { sha256Hex, utf8 } from "./sha256.ts";
5
+ import type { Cell } from "./strategy.ts";
6
+
7
+ /** An edge between two boxes. The router reads it, and the packer ignores it. */
8
+ export interface Edge {
9
+ id: string;
10
+ source: string;
11
+ target: string;
12
+ sourcePort?: string;
13
+ targetPort?: string;
14
+ }
15
+
16
+ /** The parameters of a generator: a name and a string or a number for each parameter. */
17
+ export type GeneratorParams = Record<string, string | number>;
18
+
19
+ /** The stamp of a generated input: the generator, its version, the full parameters and the seed. */
20
+ export interface GeneratorStamp {
21
+ name: string;
22
+ version: number;
23
+ params: GeneratorParams;
24
+ seed: number;
25
+ }
26
+
27
+ /** One input file `layout-input/1`. `docs/FORMAT.md` gives each field. */
28
+ export interface LayoutInput {
29
+ schema: "layout-input/1";
30
+ id: string;
31
+ set: string;
32
+ family: string;
33
+ seed: number;
34
+ /** The origin: `made-up`, `published:<set>` or `known-best:<construction>`. */
35
+ source: string;
36
+ /** The generator of a generated input. A hand-made or published input has `null`. */
37
+ generator: GeneratorStamp | null;
38
+ container: Container;
39
+ obstacles?: Obstacle[];
40
+ gap?: number;
41
+ boxes: Item[];
42
+ edges: Edge[];
43
+ base?: string;
44
+ change?: string;
45
+ step?: number;
46
+ /** The first 16 hex characters of the SHA-256 of the canonical form of the other fields. */
47
+ hash: string;
48
+ }
49
+
50
+ /** The layout of the obstacles (`docs/research/TAXONOMY.md`, dimension 3). */
51
+ export type ObstacleLayout = "none" | "center" | "scattered" | "wall" | "corridors" | "touching" | "edge";
52
+
53
+ /** The distribution of the sizes of the items (`docs/research/TAXONOMY.md`, dimension 6). */
54
+ export type SizeDistribution = "identical" | "nearIdentical" | "uniform" | "heavyTail" | "bimodal" | "extremeAspect";
55
+
56
+ /** The scale of the count of the items (`docs/research/TAXONOMY.md`, dimension 7). */
57
+ export type CountScale = "1" | "10" | "100" | "1k" | "10k";
58
+
59
+ /**
60
+ * The cell of a generated input: one value for each dimension of the packer. The cell of `cellOf` has the dimensions
61
+ * that the input tells. The generator adds the layout of the obstacles, the sizes, the count and the mode.
62
+ */
63
+ export interface CorpusCell extends Cell {
64
+ obstacles: ObstacleLayout;
65
+ sizes: SizeDistribution;
66
+ count: CountScale;
67
+ mode: "offline" | "online" | "incremental";
68
+ }
69
+
70
+ /** This function gives the `fnv1a32` hash of the UTF-8 bytes of a string, as `docs/FORMAT.md` gives it. */
71
+ export function fnv1a32(text: string): number {
72
+ let h = 0x811c9dc5;
73
+ for (const b of utf8(text)) h = Math.imul(h ^ b, 0x01000193) >>> 0;
74
+ return h;
75
+ }
76
+
77
+ /** This function rounds the numbers of a file to 3 decimals. A half goes up, and the value `-0` becomes 0. */
78
+ export function round3(x: number): number {
79
+ const v = Math.round(x * 1000) / 1000;
80
+ return Object.is(v, -0) ? 0 : v;
81
+ }
82
+
83
+ /**
84
+ * This function gives the canonical form of a value: sorted keys, no whitespace and the numbers of `JSON.stringify`
85
+ * after `round3`. A key with the value `undefined` is not in the form.
86
+ */
87
+ export function canonicalJson(value: unknown): string {
88
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
89
+ if (value !== null && typeof value === "object") {
90
+ const entries = Object.entries(value as Record<string, unknown>)
91
+ .filter(([, v]) => v !== undefined)
92
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
93
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
94
+ }
95
+ return JSON.stringify(typeof value === "number" ? round3(value) : value);
96
+ }
97
+
98
+ /** This function gives the hash of an input file: the first 16 hex characters of the SHA-256 of its canonical form without `hash`. */
99
+ export function hashInput(file: Omit<LayoutInput, "hash"> | LayoutInput): string {
100
+ const rest: Partial<LayoutInput> = { ...file };
101
+ delete rest.hash;
102
+ return sha256Hex(canonicalJson(rest)).slice(0, 16);
103
+ }
104
+
105
+ /** This function gives the scale of a count: the nearest of 1, 10, 100, 1000 and 10000 on a log scale. The count 0 gives `1`. */
106
+ export function countCell(count: number): CountScale {
107
+ if (count <= 3) return "1";
108
+ if (count <= 31) return "10";
109
+ if (count <= 316) return "100";
110
+ if (count <= 3162) return "1k";
111
+ return "10k";
112
+ }
113
+
114
+ /** This function gives the packing input of a file. The boxes become the items, and the edges go. */
115
+ export function toPackInput(file: Pick<LayoutInput, "container" | "boxes" | "obstacles" | "gap">): PackInput {
116
+ const input: PackInput = { container: file.container, items: file.boxes };
117
+ if (file.obstacles !== undefined) input.obstacles = file.obstacles;
118
+ if (file.gap !== undefined) input.gap = file.gap;
119
+ return input;
120
+ }
@@ -0,0 +1,363 @@
1
+ // The algebra of measures (roadmap item k8). `docs/MEASURES.md` defines each measure. This file computes them.
2
+
3
+ import { RECORD_EPS, type PackInput, type PackOutput } from "./contract.ts";
4
+ import { aspectError } from "./free.ts";
5
+ import { round3 } from "./layout-input.ts";
6
+
7
+ /** The direction of a measure: `lower` when a lower value is better, `higher` when a higher value is better. */
8
+ export type Direction = "lower" | "higher";
9
+
10
+ /** The family of a measure. Each family has a section in `docs/MEASURES.md`. */
11
+ export type MeasureFamily = "density" | "aspect" | "order" | "raggedness" | "overflow" | "stability" | "determinism" | "speed";
12
+
13
+ /** The id of a measure of the packer. The ids are those of `docs/FORMAT.md`, and `highways` uses the same ids. */
14
+ export type MeasureId = "pack.area" | "pack.fill" | "pack.whitespace" | "pack.aspect" | "pack.aspectError" | "pack.order.inversions" | "pack.order.breaks" | "pack.raggedness" | "pack.overflow" | "pack.stability.moved" | "pack.stability.mean" | "pack.stability.q95" | "pack.deterministic" | "pack.search.widths" | "pack.search.ms";
15
+
16
+ /** The facts of one measure: its family, its unit, its direction and its range. */
17
+ export interface MeasureSpec {
18
+ id: MeasureId;
19
+ family: MeasureFamily;
20
+ unit: string;
21
+ direction: Direction;
22
+ /** The lowest value. */
23
+ min: number;
24
+ /** The highest value. The value `Infinity` tells that the measure has no upper limit. */
25
+ max: number;
26
+ }
27
+
28
+ /** The measures of one layout, by id. `measure` gives them. The values are not rounded. */
29
+ export interface LayoutMeasures {
30
+ "pack.area": number;
31
+ "pack.fill": number;
32
+ "pack.whitespace": number;
33
+ "pack.aspect": number;
34
+ "pack.aspectError": number;
35
+ "pack.order.inversions": number;
36
+ "pack.order.breaks": number;
37
+ "pack.raggedness": number;
38
+ "pack.overflow": number;
39
+ "pack.search.widths": number;
40
+ "pack.search.ms": number;
41
+ }
42
+
43
+ /** The stability measures of a change pair, by id. `measureChange` gives them. The values are not rounded. */
44
+ export interface ChangeMeasures {
45
+ "pack.stability.moved": number;
46
+ "pack.stability.mean": number;
47
+ "pack.stability.q95": number;
48
+ }
49
+
50
+ /** The options of `measure`. */
51
+ export interface MeasureOptions {
52
+ /**
53
+ * A box joins a row when its top is this close to the top of the first box of the row. The rows give
54
+ * `pack.raggedness`. The default is half the median height of the boxes.
55
+ */
56
+ rowTolerance?: number;
57
+ }
58
+
59
+ const spec = (id: MeasureId, family: MeasureFamily, unit: string, direction: Direction, min: number, max: number): MeasureSpec => ({
60
+ id,
61
+ family,
62
+ unit,
63
+ direction,
64
+ min,
65
+ max,
66
+ });
67
+
68
+ /** The facts of each measure, by id. `docs/MEASURES.md` gives the full definitions. */
69
+ export const MEASURE_SPECS: Readonly<Record<MeasureId, MeasureSpec>> = Object.freeze({
70
+ "pack.area": spec("pack.area", "density", "square units", "lower", 0, Infinity),
71
+ "pack.fill": spec("pack.fill", "density", "share", "higher", 0, 1),
72
+ "pack.whitespace": spec("pack.whitespace", "density", "share", "lower", 0, 1),
73
+ "pack.aspect": spec("pack.aspect", "aspect", "ratio", "lower", 1, Infinity),
74
+ "pack.aspectError": spec("pack.aspectError", "aspect", "natural log", "lower", 0, Infinity),
75
+ "pack.order.inversions": spec("pack.order.inversions", "order", "share", "lower", 0, 1),
76
+ "pack.order.breaks": spec("pack.order.breaks", "order", "count", "lower", 0, Infinity),
77
+ "pack.raggedness": spec("pack.raggedness", "raggedness", "share", "lower", 0, 1),
78
+ "pack.overflow": spec("pack.overflow", "overflow", "count", "lower", 0, Infinity),
79
+ "pack.stability.moved": spec("pack.stability.moved", "stability", "share", "lower", 0, 1),
80
+ "pack.stability.mean": spec("pack.stability.mean", "stability", "container widths", "lower", 0, Infinity),
81
+ "pack.stability.q95": spec("pack.stability.q95", "stability", "container widths", "lower", 0, Infinity),
82
+ "pack.deterministic": spec("pack.deterministic", "determinism", "boolean", "higher", 0, 1),
83
+ "pack.search.widths": spec("pack.search.widths", "speed", "count", "lower", 0, Infinity),
84
+ "pack.search.ms": spec("pack.search.ms", "speed", "milliseconds", "lower", 0, Infinity),
85
+ });
86
+
87
+ /** A placed box: the rectangle of an item at its position, with its rank. */
88
+ export interface Box {
89
+ x: number;
90
+ y: number;
91
+ w: number;
92
+ h: number;
93
+ rank: number;
94
+ }
95
+
96
+ /** This function gives the smallest rectangle around the boxes. No box gives a rectangle of size 0 at 0, 0. */
97
+ export function hullOf(boxes: readonly Pick<Box, "x" | "y" | "w" | "h">[]): { x: number; y: number; w: number; h: number } {
98
+ if (boxes.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
99
+ let left = Infinity;
100
+ let top = Infinity;
101
+ let right = -Infinity;
102
+ let bottom = -Infinity;
103
+ for (const b of boxes) {
104
+ left = Math.min(left, b.x);
105
+ top = Math.min(top, b.y);
106
+ right = Math.max(right, b.x + b.w);
107
+ bottom = Math.max(bottom, b.y + b.h);
108
+ }
109
+ return { x: left, y: top, w: right - left, h: bottom - top };
110
+ }
111
+
112
+ /**
113
+ * This function puts the boxes in rows. It sorts the boxes by their top, and keeps the order of the list for equal tops.
114
+ * A box joins the last row when its top is within `tolerance` of the top of the first box of that row.
115
+ */
116
+ export function rowsOf(boxes: readonly Pick<Box, "x" | "y" | "w">[], tolerance: number): { top: number; right: number; count: number }[] {
117
+ const rows: { top: number; right: number; count: number }[] = [];
118
+ for (const b of boxes.toSorted((p, q) => p.y - q.y)) {
119
+ const row = rows.at(-1);
120
+ if (row && Math.abs(b.y - row.top) <= tolerance) {
121
+ row.right = Math.max(row.right, b.x + b.w);
122
+ row.count += 1;
123
+ } else {
124
+ rows.push({ top: b.y, right: b.x + b.w, count: 1 });
125
+ }
126
+ }
127
+ return rows;
128
+ }
129
+
130
+ /**
131
+ * This function gives the placed boxes of an output, in the order of the items of the input. A box has the size of its
132
+ * item after the turn. The list has no item without a placement, and no item with a width or a height of 0.
133
+ */
134
+ export function boxesOf(input: PackInput, output: PackOutput): Box[] {
135
+ const placements = new Map<string, PackOutput["placements"][number]>();
136
+ for (const p of output.placements) if (!placements.has(p.id)) placements.set(p.id, p);
137
+ const boxes: Box[] = [];
138
+ for (const [index, item] of input.items.entries()) {
139
+ const p = placements.get(item.id);
140
+ if (p === undefined || !(item.w > 0 && item.h > 0)) continue;
141
+ const turned = p.turn === 90;
142
+ boxes.push({ x: p.x, y: p.y, w: turned ? item.h : item.w, h: turned ? item.w : item.h, rank: item.rank ?? index });
143
+ }
144
+ return boxes;
145
+ }
146
+
147
+ /**
148
+ * This function tells if box `a` comes before box `b` in the reading order. Two boxes whose heights overlap by more than
149
+ * `RECORD_EPS` are on one line, and the left box comes first. Otherwise the upper box comes first.
150
+ */
151
+ export function readsBefore(a: Pick<Box, "x" | "y" | "h">, b: Pick<Box, "x" | "y" | "h">): boolean {
152
+ const shared = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
153
+ if (shared > RECORD_EPS) return a.x + RECORD_EPS < b.x;
154
+ return a.y < b.y;
155
+ }
156
+
157
+ /** This function sums one measure over the stages of a trace. A trace without the measure gives 0. */
158
+ function traceSum(output: PackOutput, id: string): number {
159
+ let sum = 0;
160
+ for (const stage of output.trace) sum += stage.measures[id] ?? 0;
161
+ return sum;
162
+ }
163
+
164
+ /**
165
+ * This function gives the measures of one layout, by the ids of `docs/MEASURES.md`. The values are not rounded. A
166
+ * record rounds them with `roundMeasures`.
167
+ *
168
+ * - The boxes are the placed items with a width and a height above 0. The hull is the smallest rectangle around them.
169
+ * - An empty layout gives the area 0, the aspect 1 and the aspect error 0. Its fill is 0 and its whitespace is 1.
170
+ * - The time `pack.search.ms` comes from the trace. It is not part of the determinism rule.
171
+ *
172
+ * @example
173
+ * ```ts
174
+ * const m = measure(input, packWith("shelf", input));
175
+ * m["pack.fill"]; // the area of the boxes divided by the area of the hull
176
+ * ```
177
+ */
178
+ export function measure(input: PackInput, output: PackOutput, options: MeasureOptions = {}): LayoutMeasures {
179
+ const boxes = boxesOf(input, output);
180
+ const hull = hullOf(boxes);
181
+ const hullArea = hull.w * hull.h;
182
+ const boxArea = boxes.reduce((sum, b) => sum + b.w * b.h, 0);
183
+ const fill = hullArea > 0 ? boxArea / hullArea : 0;
184
+ const empty = boxes.length === 0;
185
+ const target = input.container.aspect ?? 1;
186
+
187
+ // The reading order: each pair of boxes with different ranks, and each step of the rank order
188
+ let pairs = 0;
189
+ let inversions = 0;
190
+ for (let i = 0; i < boxes.length; i++) {
191
+ for (let j = i + 1; j < boxes.length; j++) {
192
+ const a = boxes[i]!;
193
+ const b = boxes[j]!;
194
+ if (a.rank === b.rank) continue;
195
+ pairs += 1;
196
+ const first = a.rank < b.rank ? a : b;
197
+ const second = first === a ? b : a;
198
+ if (readsBefore(second, first)) inversions += 1;
199
+ }
200
+ }
201
+ const byRank = boxes.toSorted((a, b) => a.rank - b.rank);
202
+ let breaks = 0;
203
+ for (let k = 1; k < byRank.length; k++) {
204
+ const prev = byRank[k - 1]!;
205
+ const next = byRank[k]!;
206
+ if (prev.rank !== next.rank && readsBefore(next, prev)) breaks += 1;
207
+ }
208
+
209
+ const tolerance = options.rowTolerance ?? quantileOrZero(boxes.map((b) => b.h), 0.5) / 2;
210
+ const rights = rowsOf(boxes, tolerance).map((r) => r.right);
211
+ const spread = rights.length > 0 ? Math.max(...rights) - Math.min(...rights) : 0;
212
+
213
+ return {
214
+ "pack.area": hullArea,
215
+ "pack.fill": fill,
216
+ "pack.whitespace": 1 - fill,
217
+ "pack.aspect": empty ? 1 : Math.max(hull.w / hull.h, hull.h / hull.w),
218
+ "pack.aspectError": empty ? 0 : aspectError(hull.w, hull.h, target),
219
+ "pack.order.inversions": pairs > 0 ? inversions / pairs : 0,
220
+ "pack.order.breaks": breaks,
221
+ "pack.raggedness": hull.w > 0 ? spread / hull.w : 0,
222
+ "pack.overflow": new Set(output.overflow).size,
223
+ "pack.search.widths": traceSum(output, "pack.search.widths"),
224
+ "pack.search.ms": traceSum(output, "pack.search.ms"),
225
+ };
226
+ }
227
+
228
+ /**
229
+ * This function gives the stability measures of a change pair: the run on the base input and the run on the changed
230
+ * input. The items match by id. The values are not rounded.
231
+ *
232
+ * - An item stays when both inputs have it and both outputs place it. The added and the removed items do not count.
233
+ * - A staying item moved when its `x` or its `y` changed by more than `RECORD_EPS`, or when its turn changed.
234
+ * - The distance of an item is the length of the move of its top-left corner. The unit is the width `W`.
235
+ * - `W` is the larger width of the 2 output sizes. A `W` of 0 becomes 1, so the unit is then the unit of the input.
236
+ * - No staying item gives 0 for each measure.
237
+ */
238
+ export function measureChange(baseInput: PackInput, baseOutput: PackOutput, changeInput: PackInput, changeOutput: PackOutput): ChangeMeasures {
239
+ const placed = (input: PackInput, output: PackOutput): Map<string, PackOutput["placements"][number]> => {
240
+ const ids = new Set(input.items.map((i) => i.id));
241
+ const map = new Map<string, PackOutput["placements"][number]>();
242
+ for (const p of output.placements) if (ids.has(p.id) && !map.has(p.id)) map.set(p.id, p);
243
+ return map;
244
+ };
245
+ const before = placed(baseInput, baseOutput);
246
+ const after = placed(changeInput, changeOutput);
247
+ const width = Math.max(baseOutput.size.w, changeOutput.size.w);
248
+ const unit = width > 0 ? width : 1;
249
+
250
+ const distances: number[] = [];
251
+ let moved = 0;
252
+ for (const [id, p] of before) {
253
+ const q = after.get(id);
254
+ if (q === undefined) continue;
255
+ const dx = q.x - p.x;
256
+ const dy = q.y - p.y;
257
+ if (Math.abs(dx) > RECORD_EPS || Math.abs(dy) > RECORD_EPS || (p.turn ?? 0) !== (q.turn ?? 0)) moved += 1;
258
+ distances.push(Math.hypot(dx, dy) / unit);
259
+ }
260
+ if (distances.length === 0) return { "pack.stability.moved": 0, "pack.stability.mean": 0, "pack.stability.q95": 0 };
261
+ return {
262
+ "pack.stability.moved": moved / distances.length,
263
+ "pack.stability.mean": distances.reduce((sum, d) => sum + d, 0) / distances.length,
264
+ "pack.stability.q95": quantile(distances, 0.95),
265
+ };
266
+ }
267
+
268
+ /**
269
+ * This function gives the measure `pack.deterministic` of two runs with the same input, options and seed. It gives
270
+ * `true` when the runs have the same placements, overflow, size and moved obstacles.
271
+ *
272
+ * - The placements match by id, so their order in the list does not count. The numbers must be equal.
273
+ * - The trace is not part of the rule, because its times change each time.
274
+ */
275
+ export function isDeterministic(first: PackOutput, second: PackOutput): boolean {
276
+ const key = <T extends { id: string }>(list: readonly T[]): Map<string, T> => new Map(list.map((e) => [e.id, e]));
277
+ const samePlacements = (a: PackOutput["placements"], b: PackOutput["placements"]): boolean => {
278
+ if (a.length !== b.length) return false;
279
+ const other = key(b);
280
+ return a.every((p) => {
281
+ const q = other.get(p.id);
282
+ return q !== undefined && q.x === p.x && q.y === p.y && (q.turn ?? 0) === (p.turn ?? 0);
283
+ });
284
+ };
285
+ const sameMoved = (a: PackOutput["moved"], b: PackOutput["moved"]): boolean => {
286
+ if (a.length !== b.length) return false;
287
+ const other = key(b);
288
+ return a.every((m) => {
289
+ const n = other.get(m.id);
290
+ return n !== undefined && n.x === m.x && n.y === m.y;
291
+ });
292
+ };
293
+ const overflowA = new Set(first.overflow);
294
+ const overflowB = new Set(second.overflow);
295
+ return (
296
+ first.size.w === second.size.w &&
297
+ first.size.h === second.size.h &&
298
+ overflowA.size === overflowB.size &&
299
+ [...overflowA].every((id) => overflowB.has(id)) &&
300
+ samePlacements(first.placements, second.placements) &&
301
+ sameMoved(first.moved, second.moved)
302
+ );
303
+ }
304
+
305
+ /**
306
+ * This function rounds each number of a record of measures with `round3`, as `docs/FORMAT.md` tells. A half goes up,
307
+ * and `-0` becomes 0. The function keeps the other values, and it does not change its argument.
308
+ */
309
+ export function roundMeasures<T extends object>(measures: T): T {
310
+ const out: Record<string, unknown> = {};
311
+ for (const [id, value] of Object.entries(measures)) out[id] = typeof value === "number" ? round3(value) : value;
312
+ return out as T;
313
+ }
314
+
315
+ /**
316
+ * This function gives the paired ratio of a measure on one input: the value of the candidate against the value of the
317
+ * current default. A ratio above 1 is worse, in the direction of the measure.
318
+ *
319
+ * - For `lower`, the ratio is `candidate / current`. For `higher`, it is `current / candidate`.
320
+ * - When both values are 0, the ratio is 1. When only the divisor is 0, the ratio is `Infinity`.
321
+ * - The values are 0 or more. A negative value or `NaN` throws a `RangeError`.
322
+ */
323
+ export function pairedRatio(candidate: number, current: number, direction: Direction): number {
324
+ if (!(candidate >= 0 && current >= 0)) throw new RangeError("A paired ratio needs two values of 0 or more.");
325
+ const top = direction === "lower" ? candidate : current;
326
+ const bottom = direction === "lower" ? current : candidate;
327
+ if (bottom === 0) return top === 0 ? 1 : Infinity;
328
+ return top / bottom;
329
+ }
330
+
331
+ /**
332
+ * This function gives the quantile `q` of a list of numbers, with linear interpolation between the sorted values. The
333
+ * position in the sorted list is `(n - 1) * q`. An empty list or a `q` outside 0 to 1 throws a `RangeError`.
334
+ */
335
+ export function quantile(values: readonly number[], q: number): number {
336
+ if (values.length === 0) throw new RangeError("A quantile needs one value or more.");
337
+ if (!(q >= 0 && q <= 1)) throw new RangeError("A quantile needs a q from 0 to 1.");
338
+ const sorted = values.toSorted((a, b) => a - b);
339
+ const at = (sorted.length - 1) * q;
340
+ const low = Math.floor(at);
341
+ const part = at - low;
342
+ const a = sorted[low]!;
343
+ if (part === 0) return a;
344
+ const b = sorted[low + 1]!;
345
+ if (a === b) return a;
346
+ return a + part * (b - a);
347
+ }
348
+
349
+ /** This function gives the quantile `q` of a list, and 0 for an empty list. */
350
+ function quantileOrZero(values: readonly number[], q: number): number {
351
+ return values.length === 0 ? 0 : quantile(values, q);
352
+ }
353
+
354
+ /** This function gives the median of the paired ratios of a corpus: the quantile 0.5. It is not a ratio of two medians. */
355
+ export function medianRatio(ratios: readonly number[]): number {
356
+ return quantile(ratios, 0.5);
357
+ }
358
+
359
+ /** This function gives the share of the paired ratios above a limit. An empty list throws a `RangeError`. */
360
+ export function tailShare(ratios: readonly number[], limit: number): number {
361
+ if (ratios.length === 0) throw new RangeError("A tail share needs one ratio or more.");
362
+ return ratios.filter((r) => r > limit).length / ratios.length;
363
+ }
package/src/metrics.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { hullOf, rowsOf } from "./measures.ts";
1
2
  import type { Rect } from "./rect.ts";
2
3
 
3
4
  /** The measurements of a packing. */
@@ -35,31 +36,18 @@ export interface MetricsOptions {
35
36
  */
36
37
  export function packingMetrics(rects: readonly Rect[], options: MetricsOptions = {}): PackingMetrics {
37
38
  const tolerance = options.rowTolerance ?? 40;
38
- const boxes = rects.filter((r) => r.size.x > 0 && r.size.y > 0);
39
+ const boxes = rects.filter((r) => r.size.x > 0 && r.size.y > 0).map((r) => ({ x: r.position.x, y: r.position.y, w: r.size.x, h: r.size.y }));
39
40
  if (boxes.length === 0) return { count: 0, hull: { width: 0, height: 0 }, hullAspect: 0, rows: [], raggedness: 0, utilization: 0, widestOverMedian: 0 };
40
41
 
41
- const left = Math.min(...boxes.map((r) => r.position.x));
42
- const top = Math.min(...boxes.map((r) => r.position.y));
43
- const right = Math.max(...boxes.map((r) => r.right()));
44
- const bottom = Math.max(...boxes.map((r) => r.bottom()));
45
- const hull = { width: right - left, height: bottom - top };
46
-
47
- // The rows: the rectangles by their top, and a row takes each rectangle near the top of its first rectangle
48
- const rows: { top: number; right: number; count: number }[] = [];
49
- for (const r of boxes.toSorted((a, b) => a.position.y - b.position.y)) {
50
- const row = rows.at(-1);
51
- if (row && Math.abs(r.position.y - row.top) <= tolerance) {
52
- row.right = Math.max(row.right, r.right());
53
- row.count += 1;
54
- } else {
55
- rows.push({ top: r.position.y, right: r.right(), count: 1 });
56
- }
57
- }
42
+ // The hull and the rows come from the helpers of `measure`, so the two functions agree
43
+ const box = hullOf(boxes);
44
+ const hull = { width: box.w, height: box.h };
45
+ const rows = rowsOf(boxes, tolerance);
58
46
  const rights = rows.map((row) => row.right);
59
- const widths = boxes.map((r) => r.size.x).toSorted((a, b) => a - b);
47
+ const widths = boxes.map((r) => r.w).toSorted((a, b) => a - b);
60
48
  const median = widths[Math.floor(widths.length / 2)] ?? 0;
61
49
  const widest = widths.at(-1) ?? 0;
62
- const area = boxes.reduce((sum, r) => sum + r.area(), 0);
50
+ const area = boxes.reduce((sum, r) => sum + r.w * r.h, 0);
63
51
  return {
64
52
  count: boxes.length,
65
53
  hull,
package/src/sha256.ts ADDED
@@ -0,0 +1,93 @@
1
+ // SHA-256 (FIPS 180-4) in plain TypeScript. The hash of a corpus file needs it, and a browser has no synchronous SHA-256.
2
+
3
+ /** The round constants of SHA-256. */
4
+ const K = new Uint32Array([
5
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be,
6
+ 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa,
7
+ 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85,
8
+ 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
9
+ 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f,
10
+ 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
11
+ ]);
12
+
13
+ /** The first hash value of SHA-256. */
14
+ const H0 = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
15
+
16
+ /** This function turns a 32-bit word to the right by `n` bits. */
17
+ const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
18
+
19
+ /**
20
+ * This function gives the UTF-8 bytes of a string. A lone surrogate becomes U+FFFD, as with `TextEncoder`. The
21
+ * package has no `TextEncoder` type, because its code operates on each runtime with no global types.
22
+ */
23
+ export function utf8(text: string): Uint8Array {
24
+ const out: number[] = [];
25
+ for (let i = 0; i < text.length; i++) {
26
+ let c = text.charCodeAt(i);
27
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
28
+ const next = text.charCodeAt(i + 1);
29
+ if (next >= 0xdc00 && next <= 0xdfff) {
30
+ c = 0x10000 + ((c - 0xd800) << 10) + (next - 0xdc00);
31
+ i++;
32
+ }
33
+ }
34
+ if (c >= 0xd800 && c <= 0xdfff) c = 0xfffd;
35
+ if (c < 0x80) out.push(c);
36
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
37
+ else if (c < 0x10000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
38
+ else out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
39
+ }
40
+ return Uint8Array.from(out);
41
+ }
42
+
43
+ /** This function gives the SHA-256 of the UTF-8 bytes of a string, as 64 lowercase hex characters. */
44
+ export function sha256Hex(text: string): string {
45
+ const bytes = utf8(text);
46
+ const length = bytes.length;
47
+ const padded = new Uint8Array((((length + 9 + 63) / 64) | 0) * 64);
48
+ padded.set(bytes);
49
+ padded[length] = 0x80;
50
+ const view = new DataView(padded.buffer);
51
+ const bits = length * 8;
52
+ view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000));
53
+ view.setUint32(padded.length - 4, bits >>> 0);
54
+
55
+ const hash = [...H0];
56
+ const w = new Uint32Array(64);
57
+ for (let block = 0; block < padded.length; block += 64) {
58
+ for (let t = 0; t < 16; t++) w[t] = view.getUint32(block + t * 4);
59
+ for (let t = 16; t < 64; t++) {
60
+ const a = w[t - 15]!;
61
+ const b = w[t - 2]!;
62
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
63
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
64
+ w[t] = (w[t - 16]! + s0 + w[t - 7]! + s1) | 0;
65
+ }
66
+ let [a, b, c, d, e, f, g, h] = hash as [number, number, number, number, number, number, number, number];
67
+ for (let t = 0; t < 64; t++) {
68
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
69
+ const ch = (e & f) ^ (~e & g);
70
+ const t1 = (h + s1 + ch + K[t]! + w[t]!) | 0;
71
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
72
+ const maj = (a & b) ^ (a & c) ^ (b & c);
73
+ const t2 = (s0 + maj) | 0;
74
+ h = g;
75
+ g = f;
76
+ f = e;
77
+ e = (d + t1) | 0;
78
+ d = c;
79
+ c = b;
80
+ b = a;
81
+ a = (t1 + t2) | 0;
82
+ }
83
+ hash[0] = (hash[0]! + a) | 0;
84
+ hash[1] = (hash[1]! + b) | 0;
85
+ hash[2] = (hash[2]! + c) | 0;
86
+ hash[3] = (hash[3]! + d) | 0;
87
+ hash[4] = (hash[4]! + e) | 0;
88
+ hash[5] = (hash[5]! + f) | 0;
89
+ hash[6] = (hash[6]! + g) | 0;
90
+ hash[7] = (hash[7]! + h) | 0;
91
+ }
92
+ return hash.map((x) => (x >>> 0).toString(16).padStart(8, "0")).join("");
93
+ }