@mark1russell7/highways 0.9.6 → 0.9.7
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/build/index.d.ts +42 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +210 -4
- package/build/index.js.map +1 -1
- package/package.json +2 -1
- package/src/bench/compare.ts +24 -1
- package/src/bench/measures/lanes.ts +95 -1
- package/src/bench/measures/stability.ts +47 -3
- package/src/bench/screen/measures.ts +8 -0
- package/src/bench/screen/screen.ts +4 -1
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"directory": "packages/core"
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://mark1russell7.github.io/highways/",
|
|
12
|
-
"version": "0.9.
|
|
12
|
+
"version": "0.9.7",
|
|
13
13
|
"type": "module",
|
|
14
14
|
"main": "./build/index.js",
|
|
15
15
|
"types": "./build/index.d.ts",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"profile": "node scripts/profile.ts",
|
|
49
49
|
"levels": "node scripts/levels.ts",
|
|
50
50
|
"screen": "node scripts/screen.ts",
|
|
51
|
+
"promotion": "node scripts/promotion.ts",
|
|
51
52
|
"exact": "node scripts/exact.ts",
|
|
52
53
|
"oracle": "node scripts/oracle.ts",
|
|
53
54
|
"portfolio": "node scripts/portfolio.ts",
|
package/src/bench/compare.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { measureLevelFaults, sumFaults } from "./links.ts";
|
|
|
14
14
|
import { paretoFront, type Objective } from "./measures/pareto.ts";
|
|
15
15
|
import { roomOrderFaults } from "./measures/room.ts";
|
|
16
16
|
import { boardUnit } from "./measures/routes.ts";
|
|
17
|
-
import { changeGraph, routeStability } from "./measures/stability.ts";
|
|
17
|
+
import { changeGraph, moveResponse, routeStability } from "./measures/stability.ts";
|
|
18
18
|
import { farLinks, measureBoard } from "./room.ts";
|
|
19
19
|
import { benchRun } from "./run.ts";
|
|
20
20
|
import { curveRuns, fixtureGroupOptions, FRONT_OBJECTIVES, groupRun, routeMeasures } from "./suite.ts";
|
|
@@ -146,6 +146,7 @@ export function compareStep(step: CompareStep, graph: Graph, options: CompareOpt
|
|
|
146
146
|
let rows: CompareRow[];
|
|
147
147
|
if (step === "router") {
|
|
148
148
|
const change = graph.nodes.length <= stabilityMax ? changeGraph(graph, seed) : undefined;
|
|
149
|
+
const move = graph.nodes.length <= stabilityMax ? changeGraph(graph, seed, { edges: false }) : undefined;
|
|
149
150
|
rows = listStrategies(registry, "router").map((strategy): CompareRow => {
|
|
150
151
|
if (graph.nodes.length > (strategy.maxNodes ?? Infinity)) return { step, strategy: strategy.name, violations: 0, ms: 0, values: {}, skipped: `over ${strategy.maxNodes} nodes` };
|
|
151
152
|
const router = asRouter(strategy, own[strategy.name]);
|
|
@@ -157,6 +158,28 @@ export function compareStep(step: CompareStep, graph: Graph, options: CompareOpt
|
|
|
157
158
|
values["Z10.max"] = s.max;
|
|
158
159
|
values["Z10.moved"] = s.moved;
|
|
159
160
|
}
|
|
161
|
+
// Z11: the routes after a change that only moves one node by one unit.
|
|
162
|
+
if (move) {
|
|
163
|
+
const z = moveResponse(graph, move, result, router.route(move.graph), { seed });
|
|
164
|
+
if (z) {
|
|
165
|
+
values["Z11.gain"] = z.gain;
|
|
166
|
+
values["Z11.spill"] = z.spill;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Z12: the routes with the next seed, for a strategy with the option `seed`. A strategy without a seed gives the same routes, so its values are 0.
|
|
170
|
+
if (change) {
|
|
171
|
+
const given = (own[strategy.name] ?? {}) as { seed?: unknown };
|
|
172
|
+
const base = typeof given.seed === "number" ? given.seed : (strategy.defaults as { seed?: unknown } | undefined)?.seed;
|
|
173
|
+
if (typeof base === "number") {
|
|
174
|
+
const next = asRouter(strategy, { ...own[strategy.name], seed: base + 1 } as object);
|
|
175
|
+
const s = routeStability(graph, result, next.route(graph), { seed });
|
|
176
|
+
values["Z12.mean"] = s.mean;
|
|
177
|
+
values["Z12.moved"] = s.moved;
|
|
178
|
+
} else {
|
|
179
|
+
values["Z12.mean"] = 0;
|
|
180
|
+
values["Z12.moved"] = 0;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
160
183
|
return { step, strategy: strategy.name, violations: row.violations + (row.deterministic ? 0 : 1), ms: row.ms, values, result };
|
|
161
184
|
});
|
|
162
185
|
} else if (step === "curve") {
|
|
@@ -92,7 +92,7 @@ export function routeSeparation(routes: readonly Route[], near: number = MEASURE
|
|
|
92
92
|
if (ink <= 0) return 0;
|
|
93
93
|
const grid = new Grid<number>(Math.max(16, 4 * near));
|
|
94
94
|
legs.forEach((l, i) => grid.addSegment(i, l.a, l.b));
|
|
95
|
-
const sin =
|
|
95
|
+
const sin = parallelSine(maxAngle);
|
|
96
96
|
let close = 0;
|
|
97
97
|
legs.forEach((s, i) => {
|
|
98
98
|
const length = dist(s.a, s.b);
|
|
@@ -118,6 +118,100 @@ export function routeSeparation(routes: readonly Route[], near: number = MEASURE
|
|
|
118
118
|
return Math.min(1, close / ink);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/** This function gives the sine of the largest angle between two legs that R12 and R16 count as parallel. */
|
|
122
|
+
function parallelSine(maxAngle: number): number {
|
|
123
|
+
return Math.sin((maxAngle * Math.PI) / 180);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The lane balance of the routes (R16 of MEAS0). */
|
|
127
|
+
export interface RouteBalance {
|
|
128
|
+
/** The coefficient of variation of the gaps, weighted by the overlap: the standard deviation over the mean. 0 is even. */
|
|
129
|
+
readonly cv: number;
|
|
130
|
+
/** The share of the overlap with a gap less than half of the weighted median gap. */
|
|
131
|
+
readonly low: number;
|
|
132
|
+
/** The count of the pairs of neighbor legs. */
|
|
133
|
+
readonly pairs: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The distance in units of the board up to which two parallel legs are neighbors for R16. It is 8 lane widths. */
|
|
137
|
+
export const MEASURE_BALANCE_REACH: number = 8 * MEASURE_LANE_WIDTH;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* This function gives the lane balance of the routes (R16 of MEAS0): the spread of the gaps between parallel routes.
|
|
141
|
+
*
|
|
142
|
+
* The legs are the legs of R12. For each leg `s`, its neighbor at each side is the nearest leg `t` of other edges with three properties.
|
|
143
|
+
* It is within `maxAngle` degrees of parallel. Its projection overlaps `s`. Its distance at the middle of the overlap is less than `reach`.
|
|
144
|
+
* Each neighbor pair gives a gap `g` with the weight `w`, the length of the overlap. A pair counts one time.
|
|
145
|
+
* Two legs on one line have the gap 0.
|
|
146
|
+
*
|
|
147
|
+
* `cv = sqrt(Σ w (g − ḡ)² / Σ w) / ḡ`, with `ḡ = Σ w g / Σ w`. Lower is better. Routes without a neighbor pair give 0.
|
|
148
|
+
* R12 counts the parallel ink that is too near. R16 counts uneven gaps, also when each gap is wide.
|
|
149
|
+
*/
|
|
150
|
+
export function routeBalance(routes: readonly Route[], reach: number = MEASURE_BALANCE_REACH, maxAngle = 5): RouteBalance {
|
|
151
|
+
const legs = legsOf(routes);
|
|
152
|
+
const grid = new Grid<number>(Math.max(16, reach));
|
|
153
|
+
legs.forEach((l, i) => grid.addSegment(i, l.a, l.b));
|
|
154
|
+
const sin = parallelSine(maxAngle);
|
|
155
|
+
// The key of a pair is the two indices in order, so a pair that each leg finds counts one time.
|
|
156
|
+
const gaps = new Map<string, { g: number; w: number }>();
|
|
157
|
+
legs.forEach((s, i) => {
|
|
158
|
+
const length = dist(s.a, s.b);
|
|
159
|
+
if (length <= 0) return;
|
|
160
|
+
const ux = (s.b.x - s.a.x) / length;
|
|
161
|
+
const uy = (s.b.y - s.a.y) / length;
|
|
162
|
+
// The nearest neighbor on each side of `s`: the side +1 is the left of its direction, and -1 is the right. A gap of 0 goes to the side +1.
|
|
163
|
+
const best = new Map<number, { j: number; g: number; w: number }>();
|
|
164
|
+
grid.querySegment(s.a, s.b, reach, (j) => {
|
|
165
|
+
const t = legs[j]!;
|
|
166
|
+
if (j === i || t.edges === s.edges) return;
|
|
167
|
+
const tx = t.b.x - t.a.x;
|
|
168
|
+
const ty = t.b.y - t.a.y;
|
|
169
|
+
// The squared length of `t`. The test of the angle compares squares, so it needs no square root.
|
|
170
|
+
const tl2 = tx * tx + ty * ty;
|
|
171
|
+
const cross = ux * ty - uy * tx;
|
|
172
|
+
if (tl2 <= 0 || cross * cross > sin * sin * tl2) return;
|
|
173
|
+
const p = (t.a.x - s.a.x) * ux + (t.a.y - s.a.y) * uy;
|
|
174
|
+
const q = (t.b.x - s.a.x) * ux + (t.b.y - s.a.y) * uy;
|
|
175
|
+
const lo = Math.max(0, Math.min(p, q));
|
|
176
|
+
const hi = Math.min(length, Math.max(p, q));
|
|
177
|
+
if (hi - lo <= 1e-9) return;
|
|
178
|
+
// The offset of the line of `t` from the middle of the overlap on `s`, along the normal of `s`.
|
|
179
|
+
const m = (lo + hi) / 2;
|
|
180
|
+
const px = s.a.x + ux * m;
|
|
181
|
+
const py = s.a.y + uy * m;
|
|
182
|
+
const k = ((px - t.a.x) * tx + (py - t.a.y) * ty) / tl2;
|
|
183
|
+
const o = (t.a.x + tx * k - px) * -uy + (t.a.y + ty * k - py) * ux;
|
|
184
|
+
const g = Math.abs(o);
|
|
185
|
+
if (g >= reach) return;
|
|
186
|
+
const side = o < -1e-9 ? -1 : 1;
|
|
187
|
+
const now = best.get(side);
|
|
188
|
+
if (!now || g < now.g) best.set(side, { j, g, w: hi - lo });
|
|
189
|
+
});
|
|
190
|
+
for (const n of best.values()) {
|
|
191
|
+
const key = i < n.j ? `${i},${n.j}` : `${n.j},${i}`;
|
|
192
|
+
if (!gaps.has(key)) gaps.set(key, { g: n.g, w: n.w });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
const list = [...gaps.values()];
|
|
196
|
+
const W = list.reduce((sum, x) => sum + x.w, 0);
|
|
197
|
+
if (list.length === 0 || W <= 0) return { cv: 0, low: 0, pairs: 0 };
|
|
198
|
+
const mean = list.reduce((sum, x) => sum + x.w * x.g, 0) / W;
|
|
199
|
+
const variance = list.reduce((sum, x) => sum + x.w * (x.g - mean) ** 2, 0) / W;
|
|
200
|
+
// The weighted median: the gap where the sorted weights reach half of the sum.
|
|
201
|
+
const sorted = [...list].sort((x, y) => x.g - y.g);
|
|
202
|
+
let acc = 0;
|
|
203
|
+
let median = sorted[sorted.length - 1]!.g;
|
|
204
|
+
for (const x of sorted) {
|
|
205
|
+
acc += x.w;
|
|
206
|
+
if (acc >= W / 2) {
|
|
207
|
+
median = x.g;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const low = list.filter((x) => x.g < median / 2).reduce((sum, x) => sum + x.w, 0) / W;
|
|
212
|
+
return { cv: mean > 0 ? Math.sqrt(variance) / mean : 0, low, pairs: list.length };
|
|
213
|
+
}
|
|
214
|
+
|
|
121
215
|
/**
|
|
122
216
|
* This function gives the orthogonality of the routes (R14 of MEAS0): `orth = Σ L(legs within maxAngle degrees of an axis) / Σ_e L(r_e)`.
|
|
123
217
|
* Each route counts its own legs, also on a shared segment. The scale is 0 to 1, and higher is better for an orthogonal style.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { inflate, type Box } from "../../geom.ts";
|
|
1
|
+
import { frechet, inflate, type Box } from "../../geom.ts";
|
|
2
2
|
import { rng } from "../../fixtures.ts";
|
|
3
3
|
import type { Edge, Graph, Node, Result } from "../../model.ts";
|
|
4
4
|
import { measureStability } from "../quality.ts";
|
|
@@ -19,8 +19,9 @@ export interface GraphChange {
|
|
|
19
19
|
/**
|
|
20
20
|
* This function makes a seeded small change of a graph: one node moves by `boardUnit`, one edge goes and one new edge comes.
|
|
21
21
|
* The moved box keeps a gap of 4 units to the other boxes. The same graph and the same seed give the same change.
|
|
22
|
+
* With `edges: false`, the change only moves the node (Z11). The moved node is the node of the full change with the same seed.
|
|
22
23
|
*/
|
|
23
|
-
export function changeGraph(graph: Graph, seed: number): GraphChange {
|
|
24
|
+
export function changeGraph(graph: Graph, seed: number, options: { readonly edges?: boolean } = {}): GraphChange {
|
|
24
25
|
const R = rng(seed * 2654435761 + 97);
|
|
25
26
|
const n = graph.nodes.length;
|
|
26
27
|
const u = boardUnit(graph);
|
|
@@ -51,7 +52,7 @@ export function changeGraph(graph: Graph, seed: number): GraphChange {
|
|
|
51
52
|
let edges: Edge[] = [...graph.edges];
|
|
52
53
|
let removed: string | undefined;
|
|
53
54
|
let added: string | undefined;
|
|
54
|
-
if (edges.length > 1) {
|
|
55
|
+
if (edges.length > 1 && options.edges !== false) {
|
|
55
56
|
const j = Math.floor(R() * edges.length);
|
|
56
57
|
const gone = edges[j]!;
|
|
57
58
|
removed = gone.id;
|
|
@@ -102,3 +103,46 @@ export function routeStability(graph: Graph, before: Result, after: Result, opti
|
|
|
102
103
|
const u = boardUnit(graph);
|
|
103
104
|
return { mean: s.mean / u, max: s.max / u, moved: s.moved };
|
|
104
105
|
}
|
|
106
|
+
|
|
107
|
+
/** The move response of a router (Z11 of MEAS0): the routes after a change that only moves one node by one unit of the board. */
|
|
108
|
+
export interface MoveResponse {
|
|
109
|
+
/**
|
|
110
|
+
* The sum of the Fréchet distances of the routes, divided by the unit of the board and by the degree of the moved node (at least 1).
|
|
111
|
+
* The edges of the moved node move by approximately one unit, so 1 is a local response. A larger value tells that other routes also move.
|
|
112
|
+
*/
|
|
113
|
+
readonly gain: number;
|
|
114
|
+
/** The share of the other routes that move more than 1 unit of length, as `moved` of Z10. An other route has no end at the moved node. */
|
|
115
|
+
readonly spill: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* This function measures the move response of a router (Z11 of MEAS0). `change` is a change of `changeGraph` with `edges: false`.
|
|
120
|
+
* It gives `undefined` when no node moved. Above `sample` routes, a seeded sample serves, and the sum scales to all the routes.
|
|
121
|
+
*/
|
|
122
|
+
export function moveResponse(graph: Graph, change: GraphChange, before: Result, after: Result, options: RouteStabilityOptions = {}): MoveResponse | undefined {
|
|
123
|
+
const node = change.moved;
|
|
124
|
+
if (node === undefined) return undefined;
|
|
125
|
+
const max = options.sample ?? 2000;
|
|
126
|
+
const prev = new Map(before.routes.map((r) => [r.edge, r.points] as const));
|
|
127
|
+
const ends = new Map(graph.edges.map((e) => [e.id, e.source.node === node || e.target.node === node] as const));
|
|
128
|
+
let routes = after.routes.filter((r) => prev.has(r.edge));
|
|
129
|
+
const all = routes.length;
|
|
130
|
+
if (routes.length > max) {
|
|
131
|
+
const R = rng(options.seed ?? 1);
|
|
132
|
+
routes = Array.from({ length: max }, () => routes[Math.floor(R() * routes.length)]!);
|
|
133
|
+
}
|
|
134
|
+
const u = boardUnit(graph);
|
|
135
|
+
const degree = graph.edges.filter((e) => ends.get(e.id) === true).length;
|
|
136
|
+
let sum = 0;
|
|
137
|
+
let far = 0;
|
|
138
|
+
let spilled = 0;
|
|
139
|
+
for (const r of routes) {
|
|
140
|
+
const f = frechet(prev.get(r.edge)!, r.points);
|
|
141
|
+
sum += f;
|
|
142
|
+
if (ends.get(r.edge) === true) continue;
|
|
143
|
+
far++;
|
|
144
|
+
if (f > 1) spilled++;
|
|
145
|
+
}
|
|
146
|
+
const scale = routes.length > 0 ? all / routes.length : 0;
|
|
147
|
+
return { gain: (sum * scale) / u / Math.max(1, degree), spill: far > 0 ? spilled / far : 0 };
|
|
148
|
+
}
|
|
@@ -98,6 +98,9 @@ const ROWS: readonly Row[] = [
|
|
|
98
98
|
// MEAS3A, for the steps `router` and `highway`. R12 is the share of the ink near a parallel leg of other edges. A narrow channel between boxes moves it.
|
|
99
99
|
["R12.share", "R12", "route.lane.separation", "0 to 1", "lower", true],
|
|
100
100
|
["R14.share", "R14", "route.orthogonality", "0 to 1", "higher", false],
|
|
101
|
+
// BALANCE-STABILITY. R16 is the spread of the gaps between neighbor parallel legs of two edges, also when each gap is wide.
|
|
102
|
+
["R16.cv", "R16", "route.balance.cv", "no unit", "lower", true, "The spread of the gaps between parallel routes: the standard deviation of the gaps over their mean. A value of 0 is even."],
|
|
103
|
+
["R16.low", "R16", "route.balance.low", "0 to 1", "lower", true, "The share of the parallel overlap with a gap less than half of the median gap."],
|
|
101
104
|
["ink", "R1", "route.ink", "units", "lower", true],
|
|
102
105
|
// The bundles.
|
|
103
106
|
["B1.inkSaved", "B1", "route.bundle.ink-saved", "0 to 1", "higher", false],
|
|
@@ -154,6 +157,11 @@ const ROWS: readonly Row[] = [
|
|
|
154
157
|
["Z10.edits.moved", "Z10", "route.stability.edits.moved", "0 to 1", "lower", false],
|
|
155
158
|
["Z10.churn", "Z10", "route.stability.highway-churn", "0 to 1", "lower", false],
|
|
156
159
|
["Z10.ids", "Z10", "route.stability.highway-ids", "0 to 1", "higher", false],
|
|
160
|
+
// BALANCE-STABILITY. Z11 is the response to a move of one node by one unit. Z12 is the change of the routes with the next seed of a strategy.
|
|
161
|
+
["Z11.gain", "Z11", "route.stability.move.gain", "no unit", "lower", false, "The movement of all the routes after one node moves by one unit, for each edge of that node. A value of 1 is a local response."],
|
|
162
|
+
["Z11.spill", "Z11", "route.stability.move.spill", "0 to 1", "lower", false, "The share of the routes of other nodes that move after one node moves by one unit. A value of 0 is a local response."],
|
|
163
|
+
["Z12.mean", "Z12", "route.stability.seed.mean", "units of the board", "lower", false, "The mean movement of the routes with the next seed of the strategy. A strategy without a seed has 0."],
|
|
164
|
+
["Z12.moved", "Z12", "route.stability.seed.moved", "0 to 1", "lower", false, "The share of the routes that move with the next seed of the strategy. A strategy without a seed has 0."],
|
|
157
165
|
// The zoom levels and the groups. The default grouping rule reads the positions, so most of these measures depend on the placement.
|
|
158
166
|
["level-nesting", "Z6", "route.level.nesting", "count", "lower", false],
|
|
159
167
|
// MEAS3A, for the steps `router`, `highway` and `group`. Z2 is the count of the hard rule `link-ends`. Z9 needs a result with zoom levels.
|
|
@@ -6,7 +6,7 @@ import { asRouter, createRegistry, getStrategy, listStrategies, register, STEPS,
|
|
|
6
6
|
import { compareStep, highwayRouters, isRoutedStep, stepRouters, wholeBoard, type CompareRow, type CompareStep, type RoutedStep, type StepComparison } from "../compare.ts";
|
|
7
7
|
import { bundledShare, bundleStretch, mergeAngle } from "../measures/bundles.ts";
|
|
8
8
|
import { highwayEdits } from "../measures/highways.ts";
|
|
9
|
-
import { laneArea, laneCrossings, laneFidelity, lanesThrough, occupiedArea, openLanes, orthogonality, routeSeparation, runProfile } from "../measures/lanes.ts";
|
|
9
|
+
import { laneArea, laneCrossings, laneFidelity, lanesThrough, occupiedArea, openLanes, orthogonality, routeBalance, routeSeparation, runProfile } from "../measures/lanes.ts";
|
|
10
10
|
import { farLevel, groupFill, itemOverlaps, silhouette } from "../measures/groups.ts";
|
|
11
11
|
import { levelRatio, linkAgreement } from "../measures/levels.ts";
|
|
12
12
|
import { orderFragility } from "../measures/order.ts";
|
|
@@ -274,6 +274,9 @@ function rowValues(step: ScreenStep, graph: Graph, row: CompareRow, base: () =>
|
|
|
274
274
|
const open = openLanes(row.result);
|
|
275
275
|
values["R12.share"] = routeSeparation(routes);
|
|
276
276
|
values["R14.share"] = orthogonality(routes);
|
|
277
|
+
const balance = routeBalance(routes);
|
|
278
|
+
values["R16.cv"] = balance.cv;
|
|
279
|
+
values["R16.low"] = balance.low;
|
|
277
280
|
values["B5.crossings"] = laneCrossings(open.graph);
|
|
278
281
|
values["B7.ratio"] = occupiedArea(routes, boardUnit(graph));
|
|
279
282
|
const runs = runProfile(open.graph);
|