@kgdiff/renderer 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.
- package/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/atlas.d.ts +55 -0
- package/dist/atlas.d.ts.map +1 -0
- package/dist/atlas.js +132 -0
- package/dist/atlas.js.map +1 -0
- package/dist/camera.d.ts +116 -0
- package/dist/camera.d.ts.map +1 -0
- package/dist/camera.js +368 -0
- package/dist/camera.js.map +1 -0
- package/dist/color.d.ts +12 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +51 -0
- package/dist/color.js.map +1 -0
- package/dist/ctx.d.ts +57 -0
- package/dist/ctx.d.ts.map +1 -0
- package/dist/ctx.js +28 -0
- package/dist/ctx.js.map +1 -0
- package/dist/draw/background.d.ts +33 -0
- package/dist/draw/background.d.ts.map +1 -0
- package/dist/draw/background.js +105 -0
- package/dist/draw/background.js.map +1 -0
- package/dist/draw/badges.d.ts +26 -0
- package/dist/draw/badges.d.ts.map +1 -0
- package/dist/draw/badges.js +82 -0
- package/dist/draw/badges.js.map +1 -0
- package/dist/draw/collapsed.d.ts +15 -0
- package/dist/draw/collapsed.d.ts.map +1 -0
- package/dist/draw/collapsed.js +67 -0
- package/dist/draw/collapsed.js.map +1 -0
- package/dist/draw/edges.d.ts +14 -0
- package/dist/draw/edges.d.ts.map +1 -0
- package/dist/draw/edges.js +115 -0
- package/dist/draw/edges.js.map +1 -0
- package/dist/draw/glyphs.d.ts +31 -0
- package/dist/draw/glyphs.d.ts.map +1 -0
- package/dist/draw/glyphs.js +114 -0
- package/dist/draw/glyphs.js.map +1 -0
- package/dist/draw/halos.d.ts +19 -0
- package/dist/draw/halos.d.ts.map +1 -0
- package/dist/draw/halos.js +91 -0
- package/dist/draw/halos.js.map +1 -0
- package/dist/draw/labels.d.ts +55 -0
- package/dist/draw/labels.d.ts.map +1 -0
- package/dist/draw/labels.js +300 -0
- package/dist/draw/labels.js.map +1 -0
- package/dist/draw/link.d.ts +25 -0
- package/dist/draw/link.d.ts.map +1 -0
- package/dist/draw/link.js +122 -0
- package/dist/draw/link.js.map +1 -0
- package/dist/draw/nodes.d.ts +13 -0
- package/dist/draw/nodes.d.ts.map +1 -0
- package/dist/draw/nodes.js +165 -0
- package/dist/draw/nodes.js.map +1 -0
- package/dist/draw/shapes.d.ts +7 -0
- package/dist/draw/shapes.d.ts.map +1 -0
- package/dist/draw/shapes.js +26 -0
- package/dist/draw/shapes.js.map +1 -0
- package/dist/draw/view.d.ts +36 -0
- package/dist/draw/view.d.ts.map +1 -0
- package/dist/draw/view.js +16 -0
- package/dist/draw/view.js.map +1 -0
- package/dist/frame-cache.d.ts +103 -0
- package/dist/frame-cache.d.ts.map +1 -0
- package/dist/frame-cache.js +349 -0
- package/dist/frame-cache.js.map +1 -0
- package/dist/index.d.ts +162 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/input.d.ts +81 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +329 -0
- package/dist/input.js.map +1 -0
- package/dist/labels.d.ts +103 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +203 -0
- package/dist/labels.js.map +1 -0
- package/dist/math.d.ts +33 -0
- package/dist/math.d.ts.map +1 -0
- package/dist/math.js +138 -0
- package/dist/math.js.map +1 -0
- package/dist/minimap.d.ts +56 -0
- package/dist/minimap.d.ts.map +1 -0
- package/dist/minimap.js +501 -0
- package/dist/minimap.js.map +1 -0
- package/dist/paint-stats.d.ts +17 -0
- package/dist/paint-stats.d.ts.map +1 -0
- package/dist/paint-stats.js +23 -0
- package/dist/paint-stats.js.map +1 -0
- package/dist/paint.d.ts +64 -0
- package/dist/paint.d.ts.map +1 -0
- package/dist/paint.js +197 -0
- package/dist/paint.js.map +1 -0
- package/dist/pick.d.ts +26 -0
- package/dist/pick.d.ts.map +1 -0
- package/dist/pick.js +125 -0
- package/dist/pick.js.map +1 -0
- package/dist/renderer.d.ts +28 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +470 -0
- package/dist/renderer.js.map +1 -0
- package/dist/smooth-zoom.d.ts +21 -0
- package/dist/smooth-zoom.d.ts.map +1 -0
- package/dist/smooth-zoom.js +52 -0
- package/dist/smooth-zoom.js.map +1 -0
- package/dist/spatial.d.ts +26 -0
- package/dist/spatial.d.ts.map +1 -0
- package/dist/spatial.js +103 -0
- package/dist/spatial.js.map +1 -0
- package/dist/text.d.ts +36 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +123 -0
- package/dist/text.js.map +1 -0
- package/package.json +47 -3
- package/src/atlas.ts +171 -0
- package/src/camera.ts +482 -0
- package/src/color.ts +58 -0
- package/src/ctx.ts +102 -0
- package/src/draw/background.ts +139 -0
- package/src/draw/badges.ts +93 -0
- package/src/draw/collapsed.ts +86 -0
- package/src/draw/edges.ts +135 -0
- package/src/draw/glyphs.ts +168 -0
- package/src/draw/halos.ts +101 -0
- package/src/draw/labels.ts +399 -0
- package/src/draw/link.ts +157 -0
- package/src/draw/nodes.ts +184 -0
- package/src/draw/shapes.ts +36 -0
- package/src/draw/view.ts +37 -0
- package/src/frame-cache.ts +382 -0
- package/src/index.ts +161 -0
- package/src/input.ts +370 -0
- package/src/labels.ts +269 -0
- package/src/math.ts +185 -0
- package/src/minimap.ts +545 -0
- package/src/paint-stats.ts +38 -0
- package/src/paint.ts +235 -0
- package/src/pick.ts +173 -0
- package/src/renderer.ts +539 -0
- package/src/smooth-zoom.ts +65 -0
- package/src/spatial.ts +94 -0
- package/src/text.ts +130 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halo pass. Dark themes: soft radial glows blitted from one cached gradient sprite per color.
|
|
3
|
+
* Light themes: crisp offset rings at `r + 5` px.
|
|
4
|
+
*/
|
|
5
|
+
import type { FrameVisuals, Theme } from '@kgdiff/scene';
|
|
6
|
+
import { parseColor, rgbaString } from '../color.js';
|
|
7
|
+
import { type Ctx2D, NO_DASH, type Surface, type SurfaceFactory } from '../ctx.js';
|
|
8
|
+
import type { FrameCache } from '../frame-cache.js';
|
|
9
|
+
import { TAU } from '../math.js';
|
|
10
|
+
import { CULL_MARGIN, HALO_RING_OFFSET, HALO_RING_WIDTH, NODE_MIN_R, type View } from './view.js';
|
|
11
|
+
|
|
12
|
+
/** Glow falloff: (radius fraction, alpha) — a soft, roughly Gaussian profile. */
|
|
13
|
+
const GLOW_STOPS: readonly (readonly [number, number])[] = [
|
|
14
|
+
[0, 1],
|
|
15
|
+
[0.3, 0.9],
|
|
16
|
+
[0.5, 0.6],
|
|
17
|
+
[0.7, 0.28],
|
|
18
|
+
[0.86, 0.08],
|
|
19
|
+
[1, 0],
|
|
20
|
+
];
|
|
21
|
+
const SPRITE_SIZE = 128;
|
|
22
|
+
|
|
23
|
+
export class HaloSprites {
|
|
24
|
+
private readonly cache = new Map<string, Surface | null>();
|
|
25
|
+
|
|
26
|
+
constructor(private readonly factory: SurfaceFactory) {}
|
|
27
|
+
|
|
28
|
+
clear(): void {
|
|
29
|
+
this.cache.clear();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get size(): number {
|
|
33
|
+
return this.cache.size;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
sprite(color: string): Surface | null {
|
|
37
|
+
let s = this.cache.get(color);
|
|
38
|
+
if (s === undefined) {
|
|
39
|
+
s = this.make(color);
|
|
40
|
+
if (this.cache.size > 64) this.cache.clear();
|
|
41
|
+
this.cache.set(color, s);
|
|
42
|
+
}
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private make(color: string): Surface | null {
|
|
47
|
+
const s = this.factory(SPRITE_SIZE, SPRITE_SIZE);
|
|
48
|
+
if (!s) return null;
|
|
49
|
+
const h = SPRITE_SIZE / 2;
|
|
50
|
+
const g = s.ctx.createRadialGradient(h, h, 0, h, h, h);
|
|
51
|
+
const rgba = parseColor(color);
|
|
52
|
+
for (const [t, a] of GLOW_STOPS) {
|
|
53
|
+
g.addColorStop(t, rgba ? rgbaString(rgba, a) : a > 0 ? color : 'rgba(0, 0, 0, 0)');
|
|
54
|
+
}
|
|
55
|
+
s.ctx.fillStyle = g;
|
|
56
|
+
s.ctx.fillRect(0, 0, SPRITE_SIZE, SPRITE_SIZE);
|
|
57
|
+
return s;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function drawHalos(
|
|
62
|
+
ctx: Ctx2D,
|
|
63
|
+
frame: FrameVisuals,
|
|
64
|
+
cache: FrameCache,
|
|
65
|
+
v: View,
|
|
66
|
+
theme: Theme,
|
|
67
|
+
sprites: HaloSprites,
|
|
68
|
+
): void {
|
|
69
|
+
const { k, ox, oy } = v;
|
|
70
|
+
if (!theme.glow) {
|
|
71
|
+
ctx.lineWidth = HALO_RING_WIDTH;
|
|
72
|
+
ctx.setLineDash(NO_DASH);
|
|
73
|
+
}
|
|
74
|
+
for (const i of cache.halos) {
|
|
75
|
+
const n = frame.nodes[i]!;
|
|
76
|
+
const halo = n.halo!;
|
|
77
|
+
const a = halo.alpha * n.alpha;
|
|
78
|
+
if (a < 0.01) continue;
|
|
79
|
+
const x = n.x * k + ox;
|
|
80
|
+
const y = n.y * k + oy;
|
|
81
|
+
const r = Math.max(n.r * k, NODE_MIN_R);
|
|
82
|
+
// Light halos sit at r + 5 px, pushed outside a concentric ring when the node has one.
|
|
83
|
+
const ring = n.ring;
|
|
84
|
+
const ringOuter = ring ? r + ring.gap + ring.width / 2 : 0;
|
|
85
|
+
const base = Math.max(halo.radius * k, NODE_MIN_R); // light themes: radius = r
|
|
86
|
+
const lightR = Math.max(base + HALO_RING_OFFSET, ringOuter + 1.5 + HALO_RING_WIDTH / 2);
|
|
87
|
+
const hr = theme.glow ? Math.max(halo.radius * k, r * 1.6) : lightR + HALO_RING_WIDTH;
|
|
88
|
+
if (x + hr < v.x0 - CULL_MARGIN || x - hr > v.x1 + CULL_MARGIN) continue;
|
|
89
|
+
if (y + hr < v.y0 - CULL_MARGIN || y - hr > v.y1 + CULL_MARGIN) continue;
|
|
90
|
+
ctx.globalAlpha = a;
|
|
91
|
+
if (theme.glow) {
|
|
92
|
+
const s = sprites.sprite(halo.color);
|
|
93
|
+
if (s) ctx.drawImage(s.canvas, x - hr, y - hr, hr * 2, hr * 2);
|
|
94
|
+
} else {
|
|
95
|
+
ctx.strokeStyle = halo.color;
|
|
96
|
+
ctx.beginPath();
|
|
97
|
+
ctx.arc(x, y, lightR, 0, TAU);
|
|
98
|
+
ctx.stroke();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Label pass: collect visible labels (node primary + secondary blocks, edge labels at curve
|
|
3
|
+
* midpoints, glyph labels) and collapsed-group pills, place them greedily against node discs and
|
|
4
|
+
* badges, then draw the labels from the label atlas (or directly without offscreen surfaces).
|
|
5
|
+
* Placement runs before anything is drawn, so pills (drawn early, under edges) know whether
|
|
6
|
+
* they won their spot; a pill that collides is hidden at this zoom.
|
|
7
|
+
*/
|
|
8
|
+
import type { FrameVisuals, LabelVisual, Theme } from '@kgdiff/scene';
|
|
9
|
+
import { ATLAS_PAD, type AtlasEntry, drawTextLine, type LabelAtlas } from '../atlas.js';
|
|
10
|
+
import type { Ctx2D } from '../ctx.js';
|
|
11
|
+
import type { FrameCache } from '../frame-cache.js';
|
|
12
|
+
import {
|
|
13
|
+
type CollisionGrid,
|
|
14
|
+
createRequest,
|
|
15
|
+
type LabelClip,
|
|
16
|
+
type LabelRequest,
|
|
17
|
+
MODE_FIXED,
|
|
18
|
+
MODE_NODE,
|
|
19
|
+
MODE_POINT,
|
|
20
|
+
placeLabels,
|
|
21
|
+
SIDE_ABOVE,
|
|
22
|
+
SIDE_BELOW,
|
|
23
|
+
SIDE_CENTER,
|
|
24
|
+
SIDE_LEFT,
|
|
25
|
+
SIDE_RIGHT,
|
|
26
|
+
} from '../labels.js';
|
|
27
|
+
import { snap } from '../math.js';
|
|
28
|
+
import { type Fonts, LABEL_MAX_WIDTH, lineHeight, type TextMeasurer } from '../text.js';
|
|
29
|
+
import { pillRect } from './collapsed.js';
|
|
30
|
+
import { isLoop } from './edges.js';
|
|
31
|
+
import { SUGGESTION_MARK_R } from './glyphs.js';
|
|
32
|
+
import { linkMidpoint, loopCenterY, loopRadius } from './link.js';
|
|
33
|
+
import { NODE_MIN_R, type View } from './view.js';
|
|
34
|
+
|
|
35
|
+
/** Labels whose anchor is this far (px) outside the region still compete for space. */
|
|
36
|
+
export const LABEL_MARGIN = 160;
|
|
37
|
+
/** Nodes at least this big (screen radius, px) keep labels off their disc. */
|
|
38
|
+
export const OBSTACLE_MIN_R = 3;
|
|
39
|
+
const MIN_ALPHA = 0.02;
|
|
40
|
+
/**
|
|
41
|
+
* Collision priority of collapsed pills, on the scene's label scale: below changed-node labels
|
|
42
|
+
* (1 + importance; ties go to labels) and above neutral ones (importance < 1) and edge labels.
|
|
43
|
+
*/
|
|
44
|
+
export const PILL_PRIORITY = 1;
|
|
45
|
+
|
|
46
|
+
const rect = new Float64Array(4);
|
|
47
|
+
|
|
48
|
+
export interface LabelResources {
|
|
49
|
+
readonly fonts: Fonts;
|
|
50
|
+
readonly measurer: TextMeasurer;
|
|
51
|
+
readonly atlas: LabelAtlas | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* LOD fade: fully visible from `minZoom` on (as specified), fading in over the 10% of zoom just
|
|
56
|
+
* below it, so labels never pop in or out while zooming.
|
|
57
|
+
*/
|
|
58
|
+
export function lodAlpha(minZoom: number, k: number): number {
|
|
59
|
+
if (!(minZoom > 0) || k >= minZoom) return 1;
|
|
60
|
+
const t = (k - 0.9 * minZoom) / (0.1 * minZoom);
|
|
61
|
+
return t <= 0 ? 0 : t * t * (3 - 2 * t);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function shown(l: LabelVisual | null, k: number, ownerAlpha: number): l is LabelVisual {
|
|
65
|
+
return (
|
|
66
|
+
l !== null && l.text.length > 0 && l.alpha * ownerAlpha * lodAlpha(l.minZoom, k) >= MIN_ALPHA
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class LabelPass {
|
|
71
|
+
private readonly pool: LabelRequest[] = [];
|
|
72
|
+
private readonly active: LabelRequest[] = [];
|
|
73
|
+
private readonly clip: LabelClip = { x0: 0, y0: 0, x1: 0, y1: 0, strictX: false };
|
|
74
|
+
|
|
75
|
+
/** Reset `grid` to the region and add node discs as obstacles. */
|
|
76
|
+
begin(frame: FrameVisuals, cache: FrameCache, v: View, grid: CollisionGrid): void {
|
|
77
|
+
const m = LABEL_MARGIN;
|
|
78
|
+
grid.reset(v.x0 - m, v.y0 - m, v.x1 - v.x0 + 2 * m, v.y1 - v.y0 + 2 * m);
|
|
79
|
+
const { k, ox, oy } = v;
|
|
80
|
+
if (cache.maxR * k < OBSTACLE_MIN_R) return;
|
|
81
|
+
for (const b of cache.nodeBuckets) {
|
|
82
|
+
for (const i of b.members) {
|
|
83
|
+
const n = frame.nodes[i]!;
|
|
84
|
+
const r = n.r * k;
|
|
85
|
+
if (r < OBSTACLE_MIN_R) continue;
|
|
86
|
+
const x = n.x * k + ox;
|
|
87
|
+
const y = n.y * k + oy;
|
|
88
|
+
if (x + r < v.x0 - m || x - r > v.x1 + m || y + r < v.y0 - m || y - r > v.y1 + m) continue;
|
|
89
|
+
grid.insert(x - r, y - r, 2 * r, 2 * r);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Collect labels and pills and place them against `grid` (call after `begin` and after badges
|
|
96
|
+
* registered their obstacles). Records which pills won a spot in `cache.pillShown`.
|
|
97
|
+
*/
|
|
98
|
+
place(
|
|
99
|
+
frame: FrameVisuals,
|
|
100
|
+
cache: FrameCache,
|
|
101
|
+
v: View,
|
|
102
|
+
res: LabelResources,
|
|
103
|
+
grid: CollisionGrid,
|
|
104
|
+
): void {
|
|
105
|
+
this.active.length = 0;
|
|
106
|
+
this.collectNodes(frame, v, res);
|
|
107
|
+
this.collectEdges(frame, v, res);
|
|
108
|
+
this.collectGlyphs(frame, cache, v, res);
|
|
109
|
+
this.collectPills(frame, v, res);
|
|
110
|
+
const pills = frame.collapsed.length;
|
|
111
|
+
if (cache.pillShown.length < pills) cache.pillShown = new Uint8Array(pills);
|
|
112
|
+
cache.pillShown.fill(0, 0, pills);
|
|
113
|
+
cache.pillsVersion = cache.version;
|
|
114
|
+
if (this.active.length === 0) return;
|
|
115
|
+
const clip = this.clip;
|
|
116
|
+
clip.x0 = v.x0;
|
|
117
|
+
clip.x1 = v.x1;
|
|
118
|
+
clip.y0 = v.y0;
|
|
119
|
+
clip.y1 = v.y1;
|
|
120
|
+
clip.strictX = v.strictX;
|
|
121
|
+
placeLabels(this.active, grid, clip);
|
|
122
|
+
for (const req of this.active) if (req.placed && req.pill >= 0) cache.pillShown[req.pill] = 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Draw the labels placed by `place`. Returns how many were drawn. */
|
|
126
|
+
draw(ctx: Ctx2D, v: View, theme: Theme, res: LabelResources): number {
|
|
127
|
+
let drawn = 0;
|
|
128
|
+
const bg = theme.canvas.background;
|
|
129
|
+
const atlas = res.atlas;
|
|
130
|
+
const source = atlas?.canvas ?? null;
|
|
131
|
+
for (const req of this.active) {
|
|
132
|
+
if (!req.placed || req.pill >= 0) continue;
|
|
133
|
+
drawn++;
|
|
134
|
+
if (req.primary) {
|
|
135
|
+
const x = alignX(req, req.wP);
|
|
136
|
+
drawLine(
|
|
137
|
+
ctx,
|
|
138
|
+
req,
|
|
139
|
+
req.primary,
|
|
140
|
+
req.textP,
|
|
141
|
+
req.fontP,
|
|
142
|
+
req.wP,
|
|
143
|
+
req.hP,
|
|
144
|
+
x,
|
|
145
|
+
req.by,
|
|
146
|
+
v,
|
|
147
|
+
bg,
|
|
148
|
+
atlas,
|
|
149
|
+
source,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (req.secondary) {
|
|
153
|
+
const x = alignX(req, req.wS);
|
|
154
|
+
drawLine(
|
|
155
|
+
ctx,
|
|
156
|
+
req,
|
|
157
|
+
req.secondary,
|
|
158
|
+
req.textS,
|
|
159
|
+
req.fontS,
|
|
160
|
+
req.wS,
|
|
161
|
+
req.hS,
|
|
162
|
+
x,
|
|
163
|
+
req.by + req.dyS,
|
|
164
|
+
v,
|
|
165
|
+
bg,
|
|
166
|
+
atlas,
|
|
167
|
+
source,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return drawn;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private take(): LabelRequest {
|
|
175
|
+
const i = this.active.length;
|
|
176
|
+
let req = this.pool[i];
|
|
177
|
+
if (!req) {
|
|
178
|
+
req = createRequest();
|
|
179
|
+
this.pool.push(req);
|
|
180
|
+
}
|
|
181
|
+
this.active.push(req);
|
|
182
|
+
req.pill = -1;
|
|
183
|
+
return req;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private collectPills(frame: FrameVisuals, v: View, res: LabelResources): void {
|
|
187
|
+
const m = LABEL_MARGIN;
|
|
188
|
+
const groups = frame.collapsed;
|
|
189
|
+
for (let i = 0; i < groups.length; i++) {
|
|
190
|
+
const g = groups[i]!;
|
|
191
|
+
if (!(g.count > 0) || !Number.isFinite(g.x + g.y)) continue;
|
|
192
|
+
pillRect(g, v, res.fonts, res.measurer, rect);
|
|
193
|
+
const w = rect[2]!;
|
|
194
|
+
const h = rect[3]!;
|
|
195
|
+
const x = rect[0]! + w / 2;
|
|
196
|
+
const y = rect[1]! + h / 2;
|
|
197
|
+
if (x < v.x0 - m || x > v.x1 + m || y < v.y0 - m || y > v.y1 + m) continue;
|
|
198
|
+
const req = this.take();
|
|
199
|
+
req.pill = i;
|
|
200
|
+
req.x = x;
|
|
201
|
+
req.y = y;
|
|
202
|
+
req.r = 0;
|
|
203
|
+
req.w = w;
|
|
204
|
+
req.h = h;
|
|
205
|
+
req.offset = 0;
|
|
206
|
+
req.mode = MODE_FIXED;
|
|
207
|
+
req.priority = PILL_PRIORITY;
|
|
208
|
+
req.order = 3e7 + i;
|
|
209
|
+
req.placed = false;
|
|
210
|
+
req.primary = null;
|
|
211
|
+
req.secondary = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private collectNodes(frame: FrameVisuals, v: View, res: LabelResources): void {
|
|
216
|
+
const { k, ox, oy } = v;
|
|
217
|
+
const m = LABEL_MARGIN;
|
|
218
|
+
const nodes = frame.nodes;
|
|
219
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
220
|
+
const n = nodes[i]!;
|
|
221
|
+
if (!n.visible || n.alpha < MIN_ALPHA) continue;
|
|
222
|
+
const p = shown(n.label, k, n.alpha) ? n.label : null;
|
|
223
|
+
const s = shown(n.secondaryLabel, k, n.alpha) ? n.secondaryLabel : null;
|
|
224
|
+
if (!p && !s) continue;
|
|
225
|
+
const x = n.x * k + ox;
|
|
226
|
+
const y = n.y * k + oy;
|
|
227
|
+
if (x < v.x0 - m || x > v.x1 + m || y < v.y0 - m || y > v.y1 + m) continue;
|
|
228
|
+
const req = this.take();
|
|
229
|
+
req.x = x;
|
|
230
|
+
req.y = y;
|
|
231
|
+
req.r = Math.max(n.r * k, NODE_MIN_R);
|
|
232
|
+
req.mode = MODE_NODE;
|
|
233
|
+
req.order = i;
|
|
234
|
+
req.alpha = n.alpha;
|
|
235
|
+
this.fill(req, p, s, res);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private collectEdges(frame: FrameVisuals, v: View, res: LabelResources): void {
|
|
240
|
+
const { k, ox, oy } = v;
|
|
241
|
+
const m = LABEL_MARGIN;
|
|
242
|
+
const edges = frame.edges;
|
|
243
|
+
for (let i = 0; i < edges.length; i++) {
|
|
244
|
+
const e = edges[i]!;
|
|
245
|
+
if (e.label === null || !e.visible || e.progress < 0.999) continue;
|
|
246
|
+
if (!shown(e.label, k, e.alpha)) continue;
|
|
247
|
+
const x1 = e.x1 * k + ox;
|
|
248
|
+
const y1 = e.y1 * k + oy;
|
|
249
|
+
let x: number;
|
|
250
|
+
let y: number;
|
|
251
|
+
if (isLoop(e.x1, e.y1, e.x2, e.y2)) {
|
|
252
|
+
const r = Math.max(e.r1 * k, NODE_MIN_R);
|
|
253
|
+
const rho = loopRadius(r, e.curvature);
|
|
254
|
+
x = x1;
|
|
255
|
+
y = loopCenterY(y1, r, rho) - rho;
|
|
256
|
+
} else {
|
|
257
|
+
[x, y] = linkMidpoint(x1, y1, e.x2 * k + ox, e.y2 * k + oy, e.curvature);
|
|
258
|
+
}
|
|
259
|
+
if (x < v.x0 - m || x > v.x1 + m || y < v.y0 - m || y > v.y1 + m) continue;
|
|
260
|
+
const req = this.take();
|
|
261
|
+
req.x = x;
|
|
262
|
+
req.y = y;
|
|
263
|
+
req.r = 0;
|
|
264
|
+
req.mode = MODE_POINT;
|
|
265
|
+
req.order = 1e7 + i;
|
|
266
|
+
req.alpha = e.alpha;
|
|
267
|
+
this.fill(req, e.label, null, res);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private collectGlyphs(
|
|
272
|
+
frame: FrameVisuals,
|
|
273
|
+
cache: FrameCache,
|
|
274
|
+
v: View,
|
|
275
|
+
res: LabelResources,
|
|
276
|
+
): void {
|
|
277
|
+
const { k, ox, oy } = v;
|
|
278
|
+
const m = LABEL_MARGIN;
|
|
279
|
+
for (const i of cache.glyphArcs) {
|
|
280
|
+
const g = frame.glyphs[i]!;
|
|
281
|
+
if (!shown(g.label, k, g.alpha) || g.progress < 0.999) continue;
|
|
282
|
+
const [x, y] = linkMidpoint(
|
|
283
|
+
g.x1 * k + ox,
|
|
284
|
+
g.y1 * k + oy,
|
|
285
|
+
g.x2 * k + ox,
|
|
286
|
+
g.y2 * k + oy,
|
|
287
|
+
g.curvature,
|
|
288
|
+
);
|
|
289
|
+
if (x < v.x0 - m || x > v.x1 + m || y < v.y0 - m || y > v.y1 + m) continue;
|
|
290
|
+
const req = this.take();
|
|
291
|
+
req.x = x;
|
|
292
|
+
req.y = y;
|
|
293
|
+
// Suggestions carry a `?` mark at the midpoint: place the label around it.
|
|
294
|
+
req.r = g.kind === 'suggestion' ? SUGGESTION_MARK_R : 0;
|
|
295
|
+
req.mode = g.kind === 'suggestion' ? MODE_NODE : MODE_POINT;
|
|
296
|
+
req.order = 2e7 + i;
|
|
297
|
+
req.alpha = g.alpha;
|
|
298
|
+
this.fill(req, g.label, null, res);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Measure and size the block for a primary and / or secondary line. */
|
|
303
|
+
private fill(
|
|
304
|
+
req: LabelRequest,
|
|
305
|
+
p: LabelVisual | null,
|
|
306
|
+
s: LabelVisual | null,
|
|
307
|
+
res: LabelResources,
|
|
308
|
+
): void {
|
|
309
|
+
const { fonts, measurer } = res;
|
|
310
|
+
req.primary = p;
|
|
311
|
+
req.secondary = s;
|
|
312
|
+
req.placed = false;
|
|
313
|
+
req.dyS = 0;
|
|
314
|
+
if (p) {
|
|
315
|
+
req.fontP = fonts.get(p.size, p.weight);
|
|
316
|
+
req.textP = measurer.fit(req.fontP, p.text, LABEL_MAX_WIDTH);
|
|
317
|
+
req.wP = Math.ceil(measurer.width(req.fontP, req.textP));
|
|
318
|
+
req.hP = lineHeight(p.size);
|
|
319
|
+
}
|
|
320
|
+
if (s) {
|
|
321
|
+
req.fontS = fonts.get(s.size, s.weight);
|
|
322
|
+
req.textS = measurer.fit(req.fontS, s.text, LABEL_MAX_WIDTH);
|
|
323
|
+
req.wS = Math.ceil(measurer.width(req.fontS, req.textS));
|
|
324
|
+
req.hS = lineHeight(s.size);
|
|
325
|
+
}
|
|
326
|
+
if (p && s) {
|
|
327
|
+
req.dyS = Math.max(s.offset - p.offset, req.hP - 1);
|
|
328
|
+
req.w = Math.max(req.wP, req.wS);
|
|
329
|
+
req.h = req.dyS + req.hS;
|
|
330
|
+
req.offset = p.offset;
|
|
331
|
+
req.priority = Math.max(p.priority, s.priority);
|
|
332
|
+
} else if (p) {
|
|
333
|
+
req.w = req.wP;
|
|
334
|
+
req.h = req.hP;
|
|
335
|
+
req.offset = p.offset;
|
|
336
|
+
req.priority = p.priority;
|
|
337
|
+
} else if (s) {
|
|
338
|
+
req.w = req.wS;
|
|
339
|
+
req.h = req.hS;
|
|
340
|
+
req.offset = s.offset;
|
|
341
|
+
req.priority = s.priority;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Left x of one line inside its block (centred below / above, flush beside the node). */
|
|
347
|
+
function alignX(req: LabelRequest, width: number): number {
|
|
348
|
+
switch (req.side) {
|
|
349
|
+
case SIDE_RIGHT:
|
|
350
|
+
return req.bx;
|
|
351
|
+
case SIDE_LEFT:
|
|
352
|
+
return req.bx + req.w - width;
|
|
353
|
+
case SIDE_BELOW:
|
|
354
|
+
case SIDE_ABOVE:
|
|
355
|
+
case SIDE_CENTER:
|
|
356
|
+
return req.bx + (req.w - width) / 2;
|
|
357
|
+
default:
|
|
358
|
+
return req.bx;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function drawLine(
|
|
363
|
+
ctx: Ctx2D,
|
|
364
|
+
req: LabelRequest,
|
|
365
|
+
label: LabelVisual,
|
|
366
|
+
text: string,
|
|
367
|
+
font: string,
|
|
368
|
+
width: number,
|
|
369
|
+
height: number,
|
|
370
|
+
x: number,
|
|
371
|
+
y: number,
|
|
372
|
+
v: View,
|
|
373
|
+
bg: string,
|
|
374
|
+
atlas: LabelAtlas | null,
|
|
375
|
+
source: CanvasImageSource | null,
|
|
376
|
+
): void {
|
|
377
|
+
const alpha = label.alpha * req.alpha * lodAlpha(label.minZoom, v.k);
|
|
378
|
+
const sx = snap(x, v.dpr);
|
|
379
|
+
const sy = snap(y, v.dpr);
|
|
380
|
+
const entry: AtlasEntry | null | undefined =
|
|
381
|
+
atlas && source ? atlas.get(text, font, label.color, label.strike, width, height) : null;
|
|
382
|
+
if (entry === undefined) return; // over this frame's raster budget: drawn next frame
|
|
383
|
+
if (entry && source) {
|
|
384
|
+
ctx.globalAlpha = alpha;
|
|
385
|
+
ctx.drawImage(
|
|
386
|
+
source,
|
|
387
|
+
entry.sx,
|
|
388
|
+
entry.sy,
|
|
389
|
+
entry.sw,
|
|
390
|
+
entry.sh,
|
|
391
|
+
sx - ATLAS_PAD,
|
|
392
|
+
sy - ATLAS_PAD,
|
|
393
|
+
entry.sw / v.dpr,
|
|
394
|
+
entry.sh / v.dpr,
|
|
395
|
+
);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
drawTextLine(ctx, text, font, label.color, bg, label.strike, sx, sy, width, height, alpha);
|
|
399
|
+
}
|
package/src/draw/link.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry of one link (edge or glyph arc) in screen space: straight or quadratic, trimmed at
|
|
3
|
+
* the endpoint radii, partially drawn (`progress`), optionally ending in an arrowhead.
|
|
4
|
+
*/
|
|
5
|
+
import type { Ctx2D } from '../ctx.js';
|
|
6
|
+
import { quadAt, quadSlope, quadTrim, TAU } from '../math.js';
|
|
7
|
+
import { ARROW_LEN } from './view.js';
|
|
8
|
+
|
|
9
|
+
export const LINK_NONE = 0;
|
|
10
|
+
export const LINK_DRAWN = 1;
|
|
11
|
+
export const LINK_ARROW = 2;
|
|
12
|
+
|
|
13
|
+
/** Drawn fraction in [0, 1]; NaN draws nothing. */
|
|
14
|
+
function progressOf(p: number): number {
|
|
15
|
+
if (!(p > 0)) return 0;
|
|
16
|
+
return Math.min(p, 1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Append the visible part of the link (x1, y1) → (x2, y2) to the current path.
|
|
21
|
+
* `trim1` / `trim2` are px cut from each end; `progress` is the drawn fraction from the source
|
|
22
|
+
* (from the target when `fromTarget`). With `arrow`, the line stops short of the tip and the
|
|
23
|
+
* tip (x, y) and unit direction (ux, uy) are written to `tip`.
|
|
24
|
+
* Returns LINK_NONE, LINK_DRAWN or LINK_ARROW.
|
|
25
|
+
*/
|
|
26
|
+
export function linkPath(
|
|
27
|
+
ctx: Ctx2D,
|
|
28
|
+
x1: number,
|
|
29
|
+
y1: number,
|
|
30
|
+
x2: number,
|
|
31
|
+
y2: number,
|
|
32
|
+
curvature: number,
|
|
33
|
+
trim1: number,
|
|
34
|
+
trim2: number,
|
|
35
|
+
progress: number,
|
|
36
|
+
fromTarget: boolean,
|
|
37
|
+
arrow: boolean,
|
|
38
|
+
tip: Float64Array,
|
|
39
|
+
): number {
|
|
40
|
+
const dx = x2 - x1;
|
|
41
|
+
const dy = y2 - y1;
|
|
42
|
+
const len = Math.hypot(dx, dy);
|
|
43
|
+
const p = progressOf(progress);
|
|
44
|
+
if (!(len > 1e-6) || p <= 0) return LINK_NONE;
|
|
45
|
+
|
|
46
|
+
if (curvature === 0 || !Number.isFinite(curvature)) {
|
|
47
|
+
const ux = dx / len;
|
|
48
|
+
const uy = dy / len;
|
|
49
|
+
const a0 = trim1;
|
|
50
|
+
const a1 = len - trim2;
|
|
51
|
+
if (a1 - a0 < 0.5) return LINK_NONE;
|
|
52
|
+
let s0 = a0;
|
|
53
|
+
let s1 = a1;
|
|
54
|
+
if (p < 1) {
|
|
55
|
+
const span = (a1 - a0) * p;
|
|
56
|
+
if (fromTarget) s0 = a1 - span;
|
|
57
|
+
else s1 = a0 + span;
|
|
58
|
+
}
|
|
59
|
+
let end = s1;
|
|
60
|
+
const withArrow = arrow && s1 - s0 > ARROW_LEN * 1.2;
|
|
61
|
+
if (withArrow) {
|
|
62
|
+
end = s1 - (ARROW_LEN - 1);
|
|
63
|
+
tip[0] = x1 + ux * s1;
|
|
64
|
+
tip[1] = y1 + uy * s1;
|
|
65
|
+
tip[2] = ux;
|
|
66
|
+
tip[3] = uy;
|
|
67
|
+
}
|
|
68
|
+
ctx.moveTo(x1 + ux * s0, y1 + uy * s0);
|
|
69
|
+
ctx.lineTo(x1 + ux * end, y1 + uy * end);
|
|
70
|
+
return withArrow ? LINK_ARROW : LINK_DRAWN;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cx = (x1 + x2) / 2 - dy * curvature;
|
|
74
|
+
const cy = (y1 + y2) / 2 + dx * curvature;
|
|
75
|
+
const ta = quadTrim(x1, y1, cx, cy, x2, y2, trim1, false);
|
|
76
|
+
const tb = quadTrim(x1, y1, cx, cy, x2, y2, trim2, true);
|
|
77
|
+
if (tb - ta < 0.01) return LINK_NONE;
|
|
78
|
+
let t0 = ta;
|
|
79
|
+
let t1 = tb;
|
|
80
|
+
if (p < 1) {
|
|
81
|
+
const span = (tb - ta) * p;
|
|
82
|
+
if (fromTarget) t0 = tb - span;
|
|
83
|
+
else t1 = ta + span;
|
|
84
|
+
}
|
|
85
|
+
let tEnd = t1;
|
|
86
|
+
let withArrow = false;
|
|
87
|
+
if (arrow) {
|
|
88
|
+
const sx = quadSlope(x1, cx, x2, t1);
|
|
89
|
+
const sy = quadSlope(y1, cy, y2, t1);
|
|
90
|
+
const speed = Math.hypot(sx, sy);
|
|
91
|
+
if (speed > 1e-6) {
|
|
92
|
+
const dt = (ARROW_LEN - 1) / speed;
|
|
93
|
+
if (t1 - t0 > dt * 1.2) {
|
|
94
|
+
withArrow = true;
|
|
95
|
+
tEnd = t1 - dt;
|
|
96
|
+
tip[0] = quadAt(x1, cx, x2, t1);
|
|
97
|
+
tip[1] = quadAt(y1, cy, y2, t1);
|
|
98
|
+
tip[2] = sx / speed;
|
|
99
|
+
tip[3] = sy / speed;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Sub-curve [t0, tEnd]: control = B(t0) + (tEnd − t0) / 2 · B′(t0).
|
|
104
|
+
const px = quadAt(x1, cx, x2, t0);
|
|
105
|
+
const py = quadAt(y1, cy, y2, t0);
|
|
106
|
+
const h = (tEnd - t0) / 2;
|
|
107
|
+
ctx.moveTo(px, py);
|
|
108
|
+
ctx.quadraticCurveTo(
|
|
109
|
+
px + h * quadSlope(x1, cx, x2, t0),
|
|
110
|
+
py + h * quadSlope(y1, cy, y2, t0),
|
|
111
|
+
quadAt(x1, cx, x2, tEnd),
|
|
112
|
+
quadAt(y1, cy, y2, tEnd),
|
|
113
|
+
);
|
|
114
|
+
return withArrow ? LINK_ARROW : LINK_DRAWN;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Self-loop circle radius (px) for a node of screen radius `r`; |curvature| enlarges it. */
|
|
118
|
+
export function loopRadius(r: number, curvature: number): number {
|
|
119
|
+
const c = Number.isFinite(curvature) ? Math.min(Math.abs(curvature), 1) : 0;
|
|
120
|
+
return Math.max(6, r * 0.8) * (1 + 2 * c);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Centre y of the self-loop circle above a node at screen y `y` with radius `r`. */
|
|
124
|
+
export function loopCenterY(y: number, r: number, rho: number): number {
|
|
125
|
+
return y - r - rho * 0.5;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Append a self-loop above the node (x, y, r); `progress` sweeps the circle. */
|
|
129
|
+
export function loopPath(
|
|
130
|
+
ctx: Ctx2D,
|
|
131
|
+
x: number,
|
|
132
|
+
y: number,
|
|
133
|
+
r: number,
|
|
134
|
+
curvature: number,
|
|
135
|
+
progress: number,
|
|
136
|
+
): number {
|
|
137
|
+
const p = progressOf(progress);
|
|
138
|
+
if (p <= 0) return LINK_NONE;
|
|
139
|
+
const rho = loopRadius(r, curvature);
|
|
140
|
+
const cy = loopCenterY(y, r, rho);
|
|
141
|
+
const a0 = Math.PI / 2;
|
|
142
|
+
ctx.moveTo(x, cy + rho);
|
|
143
|
+
ctx.arc(x, cy, rho, a0, a0 + TAU * p);
|
|
144
|
+
return LINK_DRAWN;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Point on the link at its middle (t = 0.5), screen space. */
|
|
148
|
+
export function linkMidpoint(
|
|
149
|
+
x1: number,
|
|
150
|
+
y1: number,
|
|
151
|
+
x2: number,
|
|
152
|
+
y2: number,
|
|
153
|
+
curvature: number,
|
|
154
|
+
): [number, number] {
|
|
155
|
+
const c = Number.isFinite(curvature) ? curvature : 0;
|
|
156
|
+
return [(x1 + x2) / 2 - ((y2 - y1) * c) / 2, (y1 + y2) / 2 + ((x2 - x1) * c) / 2];
|
|
157
|
+
}
|