reladraw 0.19.1 → 0.21.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/README.md +2 -2
- package/SYNTAX.md +59 -8
- package/dist/ast.d.ts +26 -2
- package/dist/ast.js +15 -2
- package/dist/checks.d.ts +1 -0
- package/dist/checks.js +79 -5
- package/dist/cli.js +35 -12
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +8 -0
- package/dist/constrain.d.ts +56 -0
- package/dist/constrain.js +95 -0
- package/dist/grammar.js +2 -0
- package/dist/model.d.ts +16 -1
- package/dist/model.js +7 -0
- package/dist/page.d.ts +14 -0
- package/dist/page.js +488 -0
- package/dist/parser.js +69 -4
- package/dist/place.d.ts +74 -1
- package/dist/place.js +303 -18
- package/dist/render.d.ts +17 -0
- package/dist/render.js +101 -29
- package/dist/resolve.d.ts +14 -0
- package/dist/resolve.js +34 -7
- package/dist/themes.d.ts +1 -0
- package/dist/themes.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** An exact distance, which is two inequalities pointing opposite ways. */
|
|
2
|
+
export function fix(from, to, distance, placement) {
|
|
3
|
+
return [
|
|
4
|
+
{ from, to, weight: distance, ...(placement ? { placement } : {}) },
|
|
5
|
+
{ from: to, to: from, weight: -distance, ...(placement ? { placement } : {}) },
|
|
6
|
+
];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Solve, or report the placements that fight.
|
|
10
|
+
*
|
|
11
|
+
* Longest path from a virtual source joined to everything at zero, which is
|
|
12
|
+
* Bellman-Ford with the comparison flipped. A distance that keeps growing after
|
|
13
|
+
* one pass per node means the constraints run in a circle that demands ever
|
|
14
|
+
* more room, and that circle is exactly the set of placements to quote back.
|
|
15
|
+
*/
|
|
16
|
+
export function tightest(count, constraints) {
|
|
17
|
+
const positions = new Array(count).fill(0);
|
|
18
|
+
const cameFrom = new Array(count).fill(undefined);
|
|
19
|
+
for (let pass = 0; pass < count; pass += 1) {
|
|
20
|
+
let moved = false;
|
|
21
|
+
for (const constraint of constraints) {
|
|
22
|
+
const candidate = positions[constraint.from] + constraint.weight;
|
|
23
|
+
if (candidate > positions[constraint.to] + 1e-9) {
|
|
24
|
+
positions[constraint.to] = candidate;
|
|
25
|
+
cameFrom[constraint.to] = constraint;
|
|
26
|
+
moved = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!moved)
|
|
30
|
+
return { positions };
|
|
31
|
+
}
|
|
32
|
+
// Still moving after a full pass per node, so some loop demands more room
|
|
33
|
+
// every time round it. Walk backwards to find it.
|
|
34
|
+
for (const constraint of constraints) {
|
|
35
|
+
if (positions[constraint.from] + constraint.weight > positions[constraint.to] + 1e-9) {
|
|
36
|
+
return { contradiction: { placements: loopFrom(constraint.to, cameFrom, count) } };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { contradiction: { placements: [] } };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Which members can be driven apart from which, along one axis.
|
|
43
|
+
*
|
|
44
|
+
* A constraint says `pos[to] >= pos[from] + weight`, so it bounds `to` from
|
|
45
|
+
* below and leaves it free to move further away. Follow those edges and the
|
|
46
|
+
* question "may this one end up beyond that one?" becomes plain reachability:
|
|
47
|
+
* if `j` is reachable from `i` and `i` is not reachable from `j`, then the file
|
|
48
|
+
* lets the distance from `i` to `j` grow without limit and never lets it be
|
|
49
|
+
* closed from the other side. Pushing `j` past `i` is then the one separation
|
|
50
|
+
* the file allows, and no choice was made.
|
|
51
|
+
*
|
|
52
|
+
* Reachable both ways means the two are pinned at a fixed distance, so they
|
|
53
|
+
* cannot be separated along this axis at all. Reachable neither way means the
|
|
54
|
+
* file said nothing that orders them, which is the case the caller must refuse
|
|
55
|
+
* rather than guess at.
|
|
56
|
+
*/
|
|
57
|
+
export function reachability(count, constraints) {
|
|
58
|
+
const reach = Array.from({ length: count }, () => new Array(count).fill(false));
|
|
59
|
+
for (const constraint of constraints)
|
|
60
|
+
reach[constraint.from][constraint.to] = true;
|
|
61
|
+
for (let via = 0; via < count; via += 1) {
|
|
62
|
+
for (let from = 0; from < count; from += 1) {
|
|
63
|
+
if (!reach[from][via])
|
|
64
|
+
continue;
|
|
65
|
+
for (let to = 0; to < count; to += 1) {
|
|
66
|
+
if (reach[via][to])
|
|
67
|
+
reach[from][to] = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return reach;
|
|
72
|
+
}
|
|
73
|
+
/** The placements on the loop reached by following each position back to what set it. */
|
|
74
|
+
function loopFrom(start, cameFrom, count) {
|
|
75
|
+
let at = start;
|
|
76
|
+
for (let step = 0; step < count; step += 1) {
|
|
77
|
+
const previous = cameFrom[at];
|
|
78
|
+
if (previous === undefined)
|
|
79
|
+
break;
|
|
80
|
+
at = previous.from;
|
|
81
|
+
}
|
|
82
|
+
const placements = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
let cursor = at;
|
|
85
|
+
while (!seen.has(cursor)) {
|
|
86
|
+
seen.add(cursor);
|
|
87
|
+
const previous = cameFrom[cursor];
|
|
88
|
+
if (previous === undefined)
|
|
89
|
+
break;
|
|
90
|
+
if (previous.placement)
|
|
91
|
+
placements.push(previous.placement);
|
|
92
|
+
cursor = previous.from;
|
|
93
|
+
}
|
|
94
|
+
return placements.reverse();
|
|
95
|
+
}
|
package/dist/grammar.js
CHANGED
|
@@ -77,6 +77,8 @@ export const RELATION_WORDS = [
|
|
|
77
77
|
// `(inside top)`, `outside router right`, `via: inside p top`.
|
|
78
78
|
'inside',
|
|
79
79
|
'outside',
|
|
80
|
+
// `stays above laptop.photos`, a limit (design.org 5.6).
|
|
81
|
+
'stays',
|
|
80
82
|
// Lines (design.org 6): `straight down from z3`, `each child of pool`,
|
|
81
83
|
// `leg climb`, `via: through slot horizontal`.
|
|
82
84
|
'straight',
|
package/dist/model.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attrs, EdgeTextPlace, Kind, Mark, NodeCorners, Placement, PlacementTarget, ViaEntry, Way } from './ast.js';
|
|
1
|
+
import type { Attrs, EdgeTextPlace, Kind, Limit, Mark, NodeCorners, Placement, PlacementTarget, ViaEntry, Way } from './ast.js';
|
|
2
2
|
/** A rectangle on the page; a side or a point has no size on one axis or both. */
|
|
3
3
|
export interface Rect {
|
|
4
4
|
x: number;
|
|
@@ -30,6 +30,12 @@ export interface LayoutNode {
|
|
|
30
30
|
* a document's are read; drawing alone depends on it, never placement.
|
|
31
31
|
*/
|
|
32
32
|
corners: NodeCorners;
|
|
33
|
+
/**
|
|
34
|
+
* How wide the border is drawn, in pixels, from `border: (thickness: …)`;
|
|
35
|
+
* left out when it is the normal width. The stroke lies inside the outline,
|
|
36
|
+
* and the padding grows by the extra, so a thick border takes room.
|
|
37
|
+
*/
|
|
38
|
+
borderWidth?: number;
|
|
33
39
|
/** The text as written, before line splitting. */
|
|
34
40
|
text: string;
|
|
35
41
|
/** The text split into the lines that will be drawn, each as its runs. */
|
|
@@ -55,6 +61,8 @@ export interface LayoutNode {
|
|
|
55
61
|
* (design.org 7.2).
|
|
56
62
|
*/
|
|
57
63
|
titlePlacements?: Placement[];
|
|
64
|
+
/** `stays above laptop.photos`: what it keeps clear of (design.org 5.6). */
|
|
65
|
+
limits?: Limit[];
|
|
58
66
|
x: number;
|
|
59
67
|
y: number;
|
|
60
68
|
width: number;
|
|
@@ -136,6 +144,8 @@ export interface LayoutMark {
|
|
|
136
144
|
names: string[];
|
|
137
145
|
appearance: Attrs;
|
|
138
146
|
guide: boolean;
|
|
147
|
+
/** A region's border width in pixels, left out when normal; drawn inside its stated edge. */
|
|
148
|
+
borderWidth?: number;
|
|
139
149
|
/** Its text, if it has one, and where that sits on the page (design.org 7.4). */
|
|
140
150
|
text?: MarkTextBox;
|
|
141
151
|
line: number;
|
|
@@ -323,6 +333,11 @@ export interface Layout {
|
|
|
323
333
|
/** How a shown guide of each kind looks: `default guide node` and the rest. */
|
|
324
334
|
guideLooks: Record<'node' | 'edge' | 'point' | 'region', Attrs>;
|
|
325
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Whether a deck, which draws no outline of its own, was given one: a fill, or
|
|
338
|
+
* something about its border.
|
|
339
|
+
*/
|
|
340
|
+
export declare function outlineStated(appearance: Attrs): boolean;
|
|
326
341
|
/**
|
|
327
342
|
* Whether a node is joined to its container's text, directly or through
|
|
328
343
|
* siblings joined to it: one unit with the text, placed by the text's position
|
package/dist/model.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a deck, which draws no outline of its own, was given one: a fill, or
|
|
3
|
+
* something about its border.
|
|
4
|
+
*/
|
|
5
|
+
export function outlineStated(appearance) {
|
|
6
|
+
return appearance['fill'] !== undefined || appearance['border'] !== undefined || appearance['border.thickness'] !== undefined;
|
|
7
|
+
}
|
|
1
8
|
/**
|
|
2
9
|
* Whether a node is joined to its container's text, directly or through
|
|
3
10
|
* siblings joined to it: one unit with the text, placed by the text's position
|
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Finding } from './checks.js';
|
|
2
|
+
import type { DrawOptions } from './index.js';
|
|
3
|
+
/**
|
|
4
|
+
* `reladraw render --page`: one diagram in one self-contained HTML page, made
|
|
5
|
+
* for looking at rather than embedding. The page is the diagram's own colors,
|
|
6
|
+
* headed with its title, and it pans, zooms, goes full screen, saves the SVG
|
|
7
|
+
* or a PNG, lists the findings, and names a box under the pointer. It needs
|
|
8
|
+
* nothing running and fetches nothing, so it can be attached, mailed or
|
|
9
|
+
* committed. `name` is the file's name, the title when the file states none.
|
|
10
|
+
*/
|
|
11
|
+
export declare function drawPage(source: string, name: string, options?: DrawOptions): {
|
|
12
|
+
html: string;
|
|
13
|
+
findings: Finding[];
|
|
14
|
+
};
|
package/dist/page.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { fileChecks, findings } from './checks.js';
|
|
2
|
+
import { SourceError } from './errors.js';
|
|
3
|
+
import { blame, parse } from './parser.js';
|
|
4
|
+
import { renderTagged, themeOf } from './render.js';
|
|
5
|
+
import { resolve } from './resolve.js';
|
|
6
|
+
import { luminance } from './themes.js';
|
|
7
|
+
/**
|
|
8
|
+
* `reladraw render --page`: one diagram in one self-contained HTML page, made
|
|
9
|
+
* for looking at rather than embedding. The page is the diagram's own colors,
|
|
10
|
+
* headed with its title, and it pans, zooms, goes full screen, saves the SVG
|
|
11
|
+
* or a PNG, lists the findings, and names a box under the pointer. It needs
|
|
12
|
+
* nothing running and fetches nothing, so it can be attached, mailed or
|
|
13
|
+
* committed. `name` is the file's name, the title when the file states none.
|
|
14
|
+
*/
|
|
15
|
+
export function drawPage(source, name, options = {}) {
|
|
16
|
+
const run = (text) => {
|
|
17
|
+
const layout = resolve(parse(text), options);
|
|
18
|
+
const { svg, tagged } = renderTagged(layout, options);
|
|
19
|
+
const found = options.checks === false ? [] : findings(layout, options.checks ? options.checks(fileChecks(layout)) : fileChecks(layout));
|
|
20
|
+
const title = layout.diagram['title'] ?? name;
|
|
21
|
+
return { html: page({ title, name, svg, tagged, findings: found, theme: themeOf(layout, options) }), findings: found };
|
|
22
|
+
};
|
|
23
|
+
try {
|
|
24
|
+
return run(source);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw error instanceof SourceError ? blame(source, error, run) : error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function page({ title, name, svg, tagged, findings, theme }) {
|
|
31
|
+
const hex = (color) => /^#[0-9a-f]{6}$/i.test(color);
|
|
32
|
+
// The browser's own controls (scroll bars, focus rings) follow the page, so
|
|
33
|
+
// a dark diagram doesn't get a light one's. A color written by hand in a
|
|
34
|
+
// form that can't be measured leaves the choice to the browser.
|
|
35
|
+
const scheme = hex(theme.text) && hex(theme.background) ? (luminance(theme.text) > luminance(theme.background) ? 'dark' : 'light') : 'light dark';
|
|
36
|
+
const data = {
|
|
37
|
+
svg,
|
|
38
|
+
name,
|
|
39
|
+
findings: findings.map((finding) => ({ kind: finding.kind, message: finding.message, line: finding.line })),
|
|
40
|
+
};
|
|
41
|
+
// Replaced by functions, so a `$` in a title or a drawing is taken as written.
|
|
42
|
+
return PAGE.replace('{{scheme}}', () => scheme)
|
|
43
|
+
.replace('{{title}}', () => escapeHtml(title))
|
|
44
|
+
.replace('{{heading}}', () => escapeHtml(title))
|
|
45
|
+
.replace('{{colors}}', () => [
|
|
46
|
+
`--page: ${theme.background};`,
|
|
47
|
+
`--ink: ${theme.text};`,
|
|
48
|
+
`--muted: ${theme.mutedText};`,
|
|
49
|
+
`--rule: ${theme.boxStroke};`,
|
|
50
|
+
`--accent: ${theme.primary.color};`,
|
|
51
|
+
`--on-accent: ${theme.primary.on};`,
|
|
52
|
+
].join(' '))
|
|
53
|
+
.replace('{{drawing}}', () => tagged)
|
|
54
|
+
.replace('{{data}}', () => scriptJson(data));
|
|
55
|
+
}
|
|
56
|
+
function escapeHtml(text) {
|
|
57
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
58
|
+
}
|
|
59
|
+
/** JSON that can sit inside a `<script>` without ending it. */
|
|
60
|
+
function scriptJson(value) {
|
|
61
|
+
return JSON.stringify(value)
|
|
62
|
+
.replace(/</g, '\\u003c');
|
|
63
|
+
}
|
|
64
|
+
const PAGE = `<!doctype html>
|
|
65
|
+
<html lang="en">
|
|
66
|
+
<head>
|
|
67
|
+
<meta charset="utf-8">
|
|
68
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
69
|
+
<meta name="color-scheme" content="{{scheme}}">
|
|
70
|
+
<meta name="generator" content="reladraw">
|
|
71
|
+
<title>{{title}}</title>
|
|
72
|
+
<style>
|
|
73
|
+
:root { {{colors}} }
|
|
74
|
+
* { box-sizing: border-box; }
|
|
75
|
+
html, body { margin: 0; height: 100%; }
|
|
76
|
+
body {
|
|
77
|
+
display: flex; flex-direction: column; height: 100vh; height: 100dvh;
|
|
78
|
+
background: var(--page); color: var(--ink);
|
|
79
|
+
font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
80
|
+
}
|
|
81
|
+
header {
|
|
82
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px;
|
|
83
|
+
padding: 10px 16px;
|
|
84
|
+
border-bottom: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
|
|
85
|
+
}
|
|
86
|
+
h1 { flex: 1 1 16rem; margin: 0; font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
|
|
87
|
+
.tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
88
|
+
.tools .gap { width: 8px; }
|
|
89
|
+
button {
|
|
90
|
+
font: inherit; font-size: 13px; color: var(--ink); background: transparent;
|
|
91
|
+
border: 1px solid color-mix(in srgb, var(--rule) 70%, transparent); border-radius: 6px;
|
|
92
|
+
padding: 4px 10px; cursor: pointer; white-space: nowrap;
|
|
93
|
+
}
|
|
94
|
+
button:hover { background: color-mix(in srgb, var(--ink) 10%, transparent); }
|
|
95
|
+
button:disabled { opacity: 0.45; cursor: default; background: transparent; }
|
|
96
|
+
button[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 25%, transparent); border-color: var(--accent); }
|
|
97
|
+
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
98
|
+
#scale { min-width: 3.5em; text-align: center; color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; }
|
|
99
|
+
main { position: relative; flex: 1; display: flex; min-height: 0; }
|
|
100
|
+
#view { position: relative; flex: 1; overflow: hidden; touch-action: none; cursor: grab; }
|
|
101
|
+
#view.dragging { cursor: grabbing; }
|
|
102
|
+
#stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
|
|
103
|
+
#stage svg { display: block; }
|
|
104
|
+
#view g[data-name].hover { filter: drop-shadow(0 0 3px var(--accent)); }
|
|
105
|
+
#view g[data-name].lit { filter: drop-shadow(0 0 2px var(--accent)) drop-shadow(0 0 6px var(--accent)); animation: lit 0.6s ease-in-out 3 alternate; }
|
|
106
|
+
@keyframes lit { from { opacity: 1; } to { opacity: 0.45; } }
|
|
107
|
+
#tip {
|
|
108
|
+
position: absolute; pointer-events: none; display: none; z-index: 2;
|
|
109
|
+
padding: 2px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap;
|
|
110
|
+
background: var(--accent); color: var(--on-accent);
|
|
111
|
+
}
|
|
112
|
+
aside {
|
|
113
|
+
width: min(26rem, 38vw); display: flex; flex-direction: column; min-height: 0;
|
|
114
|
+
border-left: 1px solid color-mix(in srgb, var(--rule) 45%, transparent);
|
|
115
|
+
}
|
|
116
|
+
aside[hidden] { display: none; }
|
|
117
|
+
aside h2 { margin: 0; padding: 10px 16px 6px; font-size: 13px; font-weight: 600; }
|
|
118
|
+
aside h2 .what { display: block; font-weight: 400; font-size: 12px; color: var(--muted); }
|
|
119
|
+
aside ol { list-style: none; margin: 0; padding: 0 8px 12px; overflow: auto; }
|
|
120
|
+
aside li button {
|
|
121
|
+
display: block; width: 100%; text-align: left; white-space: normal; border-color: transparent;
|
|
122
|
+
padding: 6px 8px; margin: 2px 0;
|
|
123
|
+
}
|
|
124
|
+
aside li button.chosen { border-color: var(--accent); }
|
|
125
|
+
.where { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
126
|
+
.kind { color: var(--accent); }
|
|
127
|
+
.note { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }
|
|
128
|
+
@media (max-width: 700px) {
|
|
129
|
+
main { flex-direction: column; }
|
|
130
|
+
aside { width: auto; max-height: 40%; border-left: 0; border-top: 1px solid color-mix(in srgb, var(--rule) 45%, transparent); }
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
133
|
+
</head>
|
|
134
|
+
<body>
|
|
135
|
+
<header>
|
|
136
|
+
<h1>{{heading}}</h1>
|
|
137
|
+
<div class="tools">
|
|
138
|
+
<button id="out" type="button" title="Zoom out (-)" aria-label="Zoom out">−</button>
|
|
139
|
+
<span id="scale" aria-live="polite">100%</span>
|
|
140
|
+
<button id="in" type="button" title="Zoom in (+)" aria-label="Zoom in">+</button>
|
|
141
|
+
<button id="fit" type="button" title="The whole diagram in view (0)">Fit</button>
|
|
142
|
+
<button id="actual" type="button" title="One pixel of the drawing to one of the screen (1)">Actual size</button>
|
|
143
|
+
<span class="gap"></span>
|
|
144
|
+
<button id="full" type="button" title="Full screen (f)">Full screen</button>
|
|
145
|
+
<button id="save-svg" type="button">Save SVG</button>
|
|
146
|
+
<button id="save-png" type="button">Save PNG</button>
|
|
147
|
+
<button id="toggle-findings" type="button" aria-controls="findings"></button>
|
|
148
|
+
</div>
|
|
149
|
+
</header>
|
|
150
|
+
<main>
|
|
151
|
+
<div id="view">
|
|
152
|
+
<div id="stage">
|
|
153
|
+
{{drawing}}
|
|
154
|
+
</div>
|
|
155
|
+
<div id="tip"></div>
|
|
156
|
+
</div>
|
|
157
|
+
<aside id="findings" aria-label="Findings">
|
|
158
|
+
<h2>Findings <span class="what">where the picture, drawn as written, may not read as meant</span></h2>
|
|
159
|
+
<ol id="finding-list"></ol>
|
|
160
|
+
</aside>
|
|
161
|
+
</main>
|
|
162
|
+
<script>
|
|
163
|
+
(function () {
|
|
164
|
+
var DATA = {{data}};
|
|
165
|
+
var view = document.getElementById('view');
|
|
166
|
+
var stage = document.getElementById('stage');
|
|
167
|
+
var svg = stage.querySelector('svg');
|
|
168
|
+
var tip = document.getElementById('tip');
|
|
169
|
+
var scaleLabel = document.getElementById('scale');
|
|
170
|
+
var box = svg.viewBox.baseVal;
|
|
171
|
+
var natural = { w: box.width, h: box.height };
|
|
172
|
+
|
|
173
|
+
// The drawing is resized rather than scaled with a transform, so the
|
|
174
|
+
// browser draws it afresh at every size and it stays sharp.
|
|
175
|
+
var at = { x: 0, y: 0, s: 1 };
|
|
176
|
+
// Until the reader pans or zooms, a change of window size redraws the view
|
|
177
|
+
// the way it was asked for: as it opened, or fitted.
|
|
178
|
+
var fitted = 'open';
|
|
179
|
+
var MIN = 0.05;
|
|
180
|
+
var MAX = 20;
|
|
181
|
+
|
|
182
|
+
function apply() {
|
|
183
|
+
svg.setAttribute('width', natural.w * at.s);
|
|
184
|
+
svg.setAttribute('height', natural.h * at.s);
|
|
185
|
+
stage.style.transform = 'translate(' + at.x + 'px,' + at.y + 'px)';
|
|
186
|
+
scaleLabel.textContent = Math.round(at.s * 100) + '%';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function size() {
|
|
190
|
+
return { w: view.clientWidth, h: view.clientHeight };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function center(s) {
|
|
194
|
+
var room = size();
|
|
195
|
+
at = { s: s, x: (room.w - natural.w * s) / 2, y: (room.h - natural.h * s) / 2 };
|
|
196
|
+
apply();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function fitScale() {
|
|
200
|
+
var room = size();
|
|
201
|
+
return Math.max(MIN, Math.min((room.w - 32) / natural.w, (room.h - 32) / natural.h));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function fit() {
|
|
205
|
+
fitted = 'fit';
|
|
206
|
+
center(fitScale());
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A diagram smaller than the window opens at its own size; a larger one
|
|
210
|
+
// opens fitted.
|
|
211
|
+
function open() {
|
|
212
|
+
fitted = 'open';
|
|
213
|
+
center(Math.min(1, fitScale()));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function refit() {
|
|
217
|
+
if (fitted === 'fit') fit();
|
|
218
|
+
else if (fitted === 'open') open();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Zoom about a point of the view, which stays where it is on screen.
|
|
222
|
+
function zoomAt(factor, px, py) {
|
|
223
|
+
var s = Math.min(MAX, Math.max(MIN, at.s * factor));
|
|
224
|
+
var k = s / at.s;
|
|
225
|
+
at = { s: s, x: px - (px - at.x) * k, y: py - (py - at.y) * k };
|
|
226
|
+
fitted = false;
|
|
227
|
+
apply();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function zoomMiddle(factor) {
|
|
231
|
+
var room = size();
|
|
232
|
+
zoomAt(factor, room.w / 2, room.h / 2);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
document.getElementById('in').onclick = function () { zoomMiddle(1.25); };
|
|
236
|
+
document.getElementById('out').onclick = function () { zoomMiddle(0.8); };
|
|
237
|
+
document.getElementById('fit').onclick = fit;
|
|
238
|
+
document.getElementById('actual').onclick = function () {
|
|
239
|
+
fitted = false;
|
|
240
|
+
center(1);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
view.addEventListener('wheel', function (event) {
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
var rect = view.getBoundingClientRect();
|
|
246
|
+
// A pinch on a trackpad arrives as a wheel with ctrl held, in small steps.
|
|
247
|
+
var step = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
|
248
|
+
zoomAt(Math.exp(-step * (event.ctrlKey ? 0.01 : 0.0015)), event.clientX - rect.left, event.clientY - rect.top);
|
|
249
|
+
}, { passive: false });
|
|
250
|
+
|
|
251
|
+
// Dragging pans; two fingers pinch.
|
|
252
|
+
var pointers = new Map();
|
|
253
|
+
var pinch = null;
|
|
254
|
+
view.addEventListener('pointerdown', function (event) {
|
|
255
|
+
if (event.button !== 0) return;
|
|
256
|
+
if (event.target.closest('a')) return;
|
|
257
|
+
view.setPointerCapture(event.pointerId);
|
|
258
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
259
|
+
if (pointers.size === 2) {
|
|
260
|
+
var two = Array.from(pointers.values());
|
|
261
|
+
pinch = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
|
|
262
|
+
}
|
|
263
|
+
view.classList.add('dragging');
|
|
264
|
+
});
|
|
265
|
+
view.addEventListener('pointermove', function (event) {
|
|
266
|
+
var last = pointers.get(event.pointerId);
|
|
267
|
+
if (!last) return;
|
|
268
|
+
var now = { x: event.clientX, y: event.clientY };
|
|
269
|
+
pointers.set(event.pointerId, now);
|
|
270
|
+
if (pointers.size === 2 && pinch) {
|
|
271
|
+
var two = Array.from(pointers.values());
|
|
272
|
+
var apart = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
|
|
273
|
+
var rect = view.getBoundingClientRect();
|
|
274
|
+
zoomAt(apart / pinch, (two[0].x + two[1].x) / 2 - rect.left, (two[0].y + two[1].y) / 2 - rect.top);
|
|
275
|
+
pinch = apart;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
at.x += now.x - last.x;
|
|
279
|
+
at.y += now.y - last.y;
|
|
280
|
+
fitted = false;
|
|
281
|
+
apply();
|
|
282
|
+
});
|
|
283
|
+
function release(event) {
|
|
284
|
+
pointers.delete(event.pointerId);
|
|
285
|
+
if (pointers.size < 2) pinch = null;
|
|
286
|
+
if (pointers.size === 0) view.classList.remove('dragging');
|
|
287
|
+
}
|
|
288
|
+
view.addEventListener('pointerup', release);
|
|
289
|
+
view.addEventListener('pointercancel', release);
|
|
290
|
+
view.addEventListener('dblclick', function (event) {
|
|
291
|
+
var rect = view.getBoundingClientRect();
|
|
292
|
+
zoomAt(event.shiftKey ? 0.5 : 2, event.clientX - rect.left, event.clientY - rect.top);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
window.addEventListener('resize', refit);
|
|
296
|
+
|
|
297
|
+
/* ---- full screen ---- */
|
|
298
|
+
|
|
299
|
+
var full = document.getElementById('full');
|
|
300
|
+
if (!document.fullscreenEnabled) full.hidden = true;
|
|
301
|
+
function toggleFull() {
|
|
302
|
+
if (document.fullscreenElement) document.exitFullscreen();
|
|
303
|
+
else document.documentElement.requestFullscreen();
|
|
304
|
+
}
|
|
305
|
+
full.onclick = toggleFull;
|
|
306
|
+
document.addEventListener('fullscreenchange', function () {
|
|
307
|
+
full.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen';
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
/* ---- saving ---- */
|
|
311
|
+
|
|
312
|
+
function saveBlob(blob, name) {
|
|
313
|
+
var url = URL.createObjectURL(blob);
|
|
314
|
+
var link = document.createElement('a');
|
|
315
|
+
link.href = url;
|
|
316
|
+
link.download = name;
|
|
317
|
+
document.body.appendChild(link);
|
|
318
|
+
link.click();
|
|
319
|
+
link.remove();
|
|
320
|
+
// A tick, so the navigation has the URL before it is taken back.
|
|
321
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function flash(button, text) {
|
|
325
|
+
var was = button.textContent;
|
|
326
|
+
button.textContent = text;
|
|
327
|
+
setTimeout(function () { button.textContent = was; }, 1200);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
document.getElementById('save-svg').onclick = function () {
|
|
331
|
+
saveBlob(new Blob([DATA.svg], { type: 'image/svg+xml' }), DATA.name + '.svg');
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// The browser draws the SVG onto a canvas with the fonts the page shows it
|
|
335
|
+
// in, twice the size so it stays sharp on a high-density screen. A data URL
|
|
336
|
+
// rather than a blob one, which some browsers count as foreign and refuse
|
|
337
|
+
// to let the canvas be read back.
|
|
338
|
+
var savePng = document.getElementById('save-png');
|
|
339
|
+
savePng.onclick = function () {
|
|
340
|
+
var image = new Image();
|
|
341
|
+
image.onload = function () {
|
|
342
|
+
var canvas = document.createElement('canvas');
|
|
343
|
+
canvas.width = natural.w * 2;
|
|
344
|
+
canvas.height = natural.h * 2;
|
|
345
|
+
canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
346
|
+
canvas.toBlob(function (blob) {
|
|
347
|
+
blob ? saveBlob(blob, DATA.name + '.png') : flash(savePng, 'Failed');
|
|
348
|
+
}, 'image/png');
|
|
349
|
+
};
|
|
350
|
+
image.onerror = function () { flash(savePng, 'Failed'); };
|
|
351
|
+
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(DATA.svg);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/* ---- a thing's name under the pointer ---- */
|
|
355
|
+
|
|
356
|
+
var hovered = null;
|
|
357
|
+
view.addEventListener('pointerover', function (event) {
|
|
358
|
+
var group = event.target.closest && event.target.closest('g[data-name]');
|
|
359
|
+
if (group === hovered) return;
|
|
360
|
+
if (hovered) hovered.classList.remove('hover');
|
|
361
|
+
hovered = group;
|
|
362
|
+
if (!group) {
|
|
363
|
+
tip.style.display = 'none';
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
group.classList.add('hover');
|
|
367
|
+
tip.textContent = group.getAttribute('data-name');
|
|
368
|
+
tip.style.display = 'block';
|
|
369
|
+
});
|
|
370
|
+
view.addEventListener('pointermove', function (event) {
|
|
371
|
+
if (!hovered) return;
|
|
372
|
+
var rect = view.getBoundingClientRect();
|
|
373
|
+
var x = event.clientX - rect.left + 14;
|
|
374
|
+
var y = event.clientY - rect.top + 16;
|
|
375
|
+
// Kept inside the view, so a name near the right edge isn't cut off.
|
|
376
|
+
x = Math.min(x, rect.width - tip.offsetWidth - 4);
|
|
377
|
+
y = Math.min(y, rect.height - tip.offsetHeight - 4);
|
|
378
|
+
tip.style.left = x + 'px';
|
|
379
|
+
tip.style.top = y + 'px';
|
|
380
|
+
});
|
|
381
|
+
view.addEventListener('pointerleave', function () {
|
|
382
|
+
if (hovered) hovered.classList.remove('hover');
|
|
383
|
+
hovered = null;
|
|
384
|
+
tip.style.display = 'none';
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/* ---- findings ---- */
|
|
388
|
+
|
|
389
|
+
var panel = document.getElementById('findings');
|
|
390
|
+
var list = document.getElementById('finding-list');
|
|
391
|
+
var toggle = document.getElementById('toggle-findings');
|
|
392
|
+
var count = DATA.findings.length;
|
|
393
|
+
|
|
394
|
+
function showPanel(open) {
|
|
395
|
+
panel.hidden = !open;
|
|
396
|
+
toggle.setAttribute('aria-pressed', String(open));
|
|
397
|
+
refit();
|
|
398
|
+
}
|
|
399
|
+
if (count === 0) {
|
|
400
|
+
toggle.textContent = 'No findings';
|
|
401
|
+
toggle.disabled = true;
|
|
402
|
+
panel.hidden = true;
|
|
403
|
+
} else {
|
|
404
|
+
toggle.textContent = count === 1 ? '1 finding' : count + ' findings';
|
|
405
|
+
toggle.onclick = function () { showPanel(panel.hidden); };
|
|
406
|
+
showPanel(true);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
var lit = [];
|
|
410
|
+
var chosen = null;
|
|
411
|
+
function unlight() {
|
|
412
|
+
lit.forEach(function (group) { group.classList.remove('lit'); });
|
|
413
|
+
lit = [];
|
|
414
|
+
if (chosen) chosen.classList.remove('chosen');
|
|
415
|
+
chosen = null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// A finding names the line of the statement at fault; what that statement
|
|
419
|
+
// drew lights up, and the view moves to it if it is out of sight.
|
|
420
|
+
function light(finding, button) {
|
|
421
|
+
unlight();
|
|
422
|
+
chosen = button;
|
|
423
|
+
button.classList.add('chosen');
|
|
424
|
+
lit = Array.from(svg.querySelectorAll('g[data-line="' + finding.line + '"]'));
|
|
425
|
+
lit.forEach(function (group) {
|
|
426
|
+
group.classList.remove('lit');
|
|
427
|
+
void group.getBoundingClientRect();
|
|
428
|
+
group.classList.add('lit');
|
|
429
|
+
});
|
|
430
|
+
if (lit.length === 0) return;
|
|
431
|
+
var room = view.getBoundingClientRect();
|
|
432
|
+
var r = null;
|
|
433
|
+
lit.forEach(function (group) {
|
|
434
|
+
var b = group.getBoundingClientRect();
|
|
435
|
+
r = r ? { l: Math.min(r.l, b.left), t: Math.min(r.t, b.top), r: Math.max(r.r, b.right), b: Math.max(r.b, b.bottom) }
|
|
436
|
+
: { l: b.left, t: b.top, r: b.right, b: b.bottom };
|
|
437
|
+
});
|
|
438
|
+
var inside = r.l >= room.left && r.r <= room.right && r.t >= room.top && r.b <= room.bottom;
|
|
439
|
+
if (inside) return;
|
|
440
|
+
at.x += (room.left + room.width / 2) - (r.l + r.r) / 2;
|
|
441
|
+
at.y += (room.top + room.height / 2) - (r.t + r.b) / 2;
|
|
442
|
+
fitted = false;
|
|
443
|
+
apply();
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
DATA.findings.forEach(function (finding) {
|
|
447
|
+
var item = document.createElement('li');
|
|
448
|
+
var button = document.createElement('button');
|
|
449
|
+
button.type = 'button';
|
|
450
|
+
var where = document.createElement('span');
|
|
451
|
+
where.className = 'where';
|
|
452
|
+
where.textContent = 'line ' + finding.line + ' ';
|
|
453
|
+
var kind = document.createElement('span');
|
|
454
|
+
kind.className = 'kind';
|
|
455
|
+
kind.textContent = finding.kind;
|
|
456
|
+
button.append(where, kind, document.createElement('br'), document.createTextNode(finding.message));
|
|
457
|
+
if (!svg.querySelector('g[data-line="' + finding.line + '"]')) {
|
|
458
|
+
var note = document.createElement('span');
|
|
459
|
+
note.className = 'note';
|
|
460
|
+
note.textContent = 'Nothing drawn comes from that line.';
|
|
461
|
+
button.append(note);
|
|
462
|
+
}
|
|
463
|
+
button.onclick = function () { light(finding, button); };
|
|
464
|
+
item.append(button);
|
|
465
|
+
list.append(item);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
/* ---- keys ---- */
|
|
469
|
+
|
|
470
|
+
document.addEventListener('keydown', function (event) {
|
|
471
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
472
|
+
if (event.target.closest && event.target.closest('input, textarea')) return;
|
|
473
|
+
if (event.key === '+' || event.key === '=') zoomMiddle(1.25);
|
|
474
|
+
else if (event.key === '-' || event.key === '_') zoomMiddle(0.8);
|
|
475
|
+
else if (event.key === '0') fit();
|
|
476
|
+
else if (event.key === '1') { fitted = false; center(1); }
|
|
477
|
+
else if (event.key === 'f' && !full.hidden) toggleFull();
|
|
478
|
+
else if (event.key === 'Escape') unlight();
|
|
479
|
+
else return;
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
open();
|
|
484
|
+
})();
|
|
485
|
+
</script>
|
|
486
|
+
</body>
|
|
487
|
+
</html>
|
|
488
|
+
`;
|