@diagrammo/dgmo 0.23.0 → 0.25.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.
@@ -64,6 +64,16 @@ const WORLD_LAT_NORTH = 78;
64
64
  // the dots. A tight cluster (e.g. Bay Area cities) therefore frames as ≈ its
65
65
  // home state + neighbours rather than the whole nation. Tunable.
66
66
  const POI_ZOOM_FLOOR_DEG = 7;
67
+ // POI-only container framing reveals the region(s) that CONTAIN the dots, but a
68
+ // single POI near the edge of a tall/wide country (e.g. Cartagena at the north
69
+ // tip of Colombia) would otherwise drag the frame to that country's far edge —
70
+ // all the way to the Amazon, ~15° below the southernmost dot. Clamp the container
71
+ // union so it reveals at most this many degrees of container BEYOND the POI
72
+ // cluster on each side: northern Colombia stays for orientation, the empty
73
+ // interior is cropped. Sized so an edge cluster still reaches across a US
74
+ // state-scale container (a Bay-Area cluster sits on the coast, ~8° from the
75
+ // Nevada border, and must still show the whole of California). Tunable.
76
+ const CONTAINER_OVERSHOOT_DEG = 8;
67
77
  // Above this longitudinal span a US POI-only extent is "national" — use the
68
78
  // albers-usa composite (CONUS conic + AK/HI insets) instead of regional Mercator.
69
79
  // CONUS spans ≈58° lon; 48° is "most of the country". Tunable.
@@ -798,7 +808,11 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
798
808
  if (bb && !isWholeSphere(bb)) containerBoxes.push(bb);
799
809
  }
800
810
  const containerUnion = unionExtent(containerBoxes, points);
801
- if (containerUnion) extent = pad(containerUnion, PAD_FRACTION);
811
+ if (containerUnion)
812
+ extent = pad(
813
+ clampContainerToCluster(containerUnion, points),
814
+ PAD_FRACTION
815
+ );
802
816
  }
803
817
 
804
818
  // POI-only fit-to-cluster zoom floor. With region framing above, the extent is
@@ -934,6 +948,34 @@ function mostCommonCountry(
934
948
  return best;
935
949
  }
936
950
 
951
+ /** Asymmetric container clamp (R-poi-region overshoot guard). Container framing
952
+ * reveals the region(s) holding the POIs, but one POI at the edge of a tall/wide
953
+ * country drags the frame to that country's far edge. Cap how far the frame
954
+ * extends BEYOND the POI cluster on each side at CONTAINER_OVERSHOOT_DEG. Latitude
955
+ * always clamps; longitude clamps only when neither extent crosses the
956
+ * antimeridian seam (a wrapped extent carries east > 180), where naive min/max
957
+ * would be wrong. Never tightens past the cluster itself, so the dots stay
958
+ * framed, and never widens it — the container edge is still the outer bound. */
959
+ function clampContainerToCluster(
960
+ container: GeoExtent,
961
+ points: Array<[number, number]>
962
+ ): GeoExtent {
963
+ const poi = unionExtent([], points);
964
+ if (!poi) return container;
965
+ let [[west, south], [east, north]] = container;
966
+ const [[pWest, pSouth], [pEast, pNorth]] = poi;
967
+ south = Math.max(south, pSouth - CONTAINER_OVERSHOOT_DEG);
968
+ north = Math.min(north, pNorth + CONTAINER_OVERSHOOT_DEG);
969
+ if (east <= 180 && pEast <= 180) {
970
+ west = Math.max(west, pWest - CONTAINER_OVERSHOOT_DEG);
971
+ east = Math.min(east, pEast + CONTAINER_OVERSHOOT_DEG);
972
+ }
973
+ return [
974
+ [west, south],
975
+ [east, north],
976
+ ];
977
+ }
978
+
937
979
  function pad(e: GeoExtent, frac: number): GeoExtent {
938
980
  const dLon = (e[1][0] - e[0][0]) * frac || 1;
939
981
  const dLat = (e[1][1] - e[0][1]) * frac || 1;
package/src/map/types.ts CHANGED
@@ -142,3 +142,23 @@ export interface ParsedMap {
142
142
  readonly diagnostics: readonly DgmoError[];
143
143
  readonly error: string | null;
144
144
  }
145
+
146
+ /** Legend descriptor for a rendered map (a layout-stage output, re-exported from
147
+ * `layout.ts`). It lives here so the `legend-band` helper can consume it without
148
+ * importing `layout` — `layout` already value-imports `mapLegendBand`, so the
149
+ * reverse type import would form a layout↔legend-band cycle. */
150
+ export interface MapLayoutLegend {
151
+ readonly tagGroups: ReadonlyArray<{
152
+ name: string;
153
+ entries: ReadonlyArray<{ value: string; color: string }>;
154
+ }>;
155
+ readonly activeGroup: string | null;
156
+ readonly ramp?: {
157
+ metric?: string;
158
+ min: number;
159
+ max: number;
160
+ hue: string;
161
+ /** Low end of the ramp gradient (the land colour the fills blend from). */
162
+ base: string;
163
+ };
164
+ }
@@ -200,10 +200,6 @@ export type SequenceElement =
200
200
  | SequenceSection
201
201
  | SequenceNote;
202
202
 
203
- export function isSequenceMessage(el: SequenceElement): el is SequenceMessage {
204
- return el.kind === 'message';
205
- }
206
-
207
203
  export function isSequenceBlock(el: SequenceElement): el is SequenceBlock {
208
204
  return el.kind === 'block';
209
205
  }
@@ -27,23 +27,6 @@
27
27
  */
28
28
  export type Brand<T, B extends string> = T & { readonly __brand: B };
29
29
 
30
- /**
31
- * Cast a raw value to a branded type. The only legal "mint" point —
32
- * call this at the boundary where unbranded data (parser input,
33
- * external API) enters branded territory.
34
- *
35
- * const id = asBrand<NodeId>(rawString);
36
- *
37
- * Inverts trivially: a `Brand<T, B>` is assignable to `T` without a
38
- * cast, so consumers that want the underlying primitive lose the
39
- * brand naturally.
40
- */
41
- export function asBrand<B>(
42
- value: B extends Brand<infer T, string> ? T : never
43
- ): B {
44
- return value as B;
45
- }
46
-
47
30
  // ============================================================
48
31
  // Writable<T> — escape hatch for parsers that need a mutable
49
32
  // construction phase before returning a `readonly`-typed value.
@@ -31,6 +31,16 @@ import type {
31
31
  D3Sel,
32
32
  } from './legend-types';
33
33
 
34
+ // Vertically center an SVG <text> across engines. WebKit drops
35
+ // `dominant-baseline` on <text>, and resvg has limited support too
36
+ // (see legend-svg.ts), so we use the alphabetic baseline (the shared
37
+ // default) plus an em-relative dy. 0.32em matches legend-svg.ts's
38
+ // proven pill offset (fontSize/2 - 2 = 0.318em at 11px).
39
+ const LEGEND_TEXT_DY = '0.32em';
40
+ function centerText(sel: D3Sel): D3Sel {
41
+ return sel.attr('dy', LEGEND_TEXT_DY);
42
+ }
43
+
34
44
  // ── Main renderer ───────────────────────────────────────────
35
45
 
36
46
  export function renderLegendD3(
@@ -190,7 +200,7 @@ function renderCapsule(
190
200
  .attr('x', pill.x + pill.width / 2)
191
201
  .attr('y', LEGEND_HEIGHT / 2)
192
202
  .attr('text-anchor', 'middle')
193
- .attr('dominant-baseline', 'central')
203
+ .call(centerText)
194
204
  .attr('font-size', LEGEND_PILL_FONT_SIZE)
195
205
  .attr('font-weight', 500)
196
206
  .attr('fill', palette.text)
@@ -211,7 +221,7 @@ function renderCapsule(
211
221
  g.append('text')
212
222
  .attr('x', gr.minX)
213
223
  .attr('y', gr.textY)
214
- .attr('dominant-baseline', 'central')
224
+ .call(centerText)
215
225
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
216
226
  .attr('fill', palette.textMuted)
217
227
  .attr('pointer-events', 'none')
@@ -232,7 +242,7 @@ function renderCapsule(
232
242
  g.append('text')
233
243
  .attr('x', gr.maxX)
234
244
  .attr('y', gr.textY)
235
- .attr('dominant-baseline', 'central')
245
+ .call(centerText)
236
246
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
237
247
  .attr('fill', palette.textMuted)
238
248
  .attr('pointer-events', 'none')
@@ -259,7 +269,7 @@ function renderCapsule(
259
269
  .append('text')
260
270
  .attr('x', entry.textX)
261
271
  .attr('y', entry.textY)
262
- .attr('dominant-baseline', 'central')
272
+ .call(centerText)
263
273
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
264
274
  .attr('fill', palette.textMuted)
265
275
  .attr('font-family', FONT_FAMILY)
@@ -318,7 +328,7 @@ function renderPill(
318
328
  .attr('x', pill.width / 2)
319
329
  .attr('y', pill.height / 2)
320
330
  .attr('text-anchor', 'middle')
321
- .attr('dominant-baseline', 'central')
331
+ .call(centerText)
322
332
  .attr('font-size', LEGEND_PILL_FONT_SIZE)
323
333
  .attr('font-weight', 500)
324
334
  .attr('fill', palette.textMuted)
@@ -387,7 +397,7 @@ function renderControl(
387
397
  .attr('x', textX)
388
398
  .attr('y', ctrl.height / 2)
389
399
  .attr('text-anchor', 'middle')
390
- .attr('dominant-baseline', 'central')
400
+ .call(centerText)
391
401
  .attr('font-size', LEGEND_PILL_FONT_SIZE)
392
402
  .attr('font-weight', 500)
393
403
  .attr('fill', palette.textMuted)
@@ -422,7 +432,7 @@ function renderControl(
422
432
  .attr('x', child.width / 2)
423
433
  .attr('y', ctrl.height / 2)
424
434
  .attr('text-anchor', 'middle')
425
- .attr('dominant-baseline', 'central')
435
+ .call(centerText)
426
436
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
427
437
  .attr('fill', child.isActive ? palette.bg : palette.textMuted)
428
438
  .attr('font-family', FONT_FAMILY)
@@ -585,7 +595,7 @@ function renderControlsGroup(
585
595
  .append('text')
586
596
  .attr('x', tl.textX)
587
597
  .attr('y', tl.textY)
588
- .attr('dominant-baseline', 'central')
598
+ .call(centerText)
589
599
  .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
590
600
  .attr('fill', palette.textMuted)
591
601
  .attr('opacity', tl.active ? 1 : LEGEND_TOGGLE_OFF_OPACITY)
@@ -79,22 +79,6 @@ export const ALL_CHART_TYPES = new Set([
79
79
  'map',
80
80
  ]);
81
81
 
82
- /**
83
- * Heuristic: pipe-metadata content is structured `key: value, …` form when
84
- * the first token is a bare identifier followed by `:`. Used by parsers that
85
- * accept both shorthand-description-after-pipe and structured key-value
86
- * (pyramid, ring) to disambiguate the two.
87
- */
88
- export const PIPE_KEY_VALUE_PREFIX_RE = /^\s*[A-Za-z][A-Za-z0-9_-]*\s*:/;
89
-
90
- /**
91
- * Heuristic to detect a likely-structured tail inside an otherwise-shorthand
92
- * pipe: `, key:` somewhere in the string. Used to flag user errors like
93
- * `Inner | bare desc, color: blue` where `color: blue` is silently swallowed
94
- * into the description.
95
- */
96
- export const PIPE_LIKELY_STRUCTURED_TAIL_RE = /,\s*[A-Za-z][A-Za-z0-9_-]*\s*:/;
97
-
98
82
  /** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */
99
83
  export function measureIndent(line: string): number {
100
84
  let indent = 0;
@@ -113,11 +113,6 @@ export const ER_REGISTRY: ReservedKeyRegistry = staticRegistry([
113
113
  'domain',
114
114
  ]);
115
115
 
116
- export const CLASS_REGISTRY: ReservedKeyRegistry = staticRegistry([
117
- 'color',
118
- 'description',
119
- ]);
120
-
121
116
  export const KANBAN_REGISTRY: ReservedKeyRegistry = staticRegistry([
122
117
  'color',
123
118
  'description',
@@ -221,10 +216,3 @@ export const RACI_REGISTRY: ReservedKeyRegistry = staticRegistry([
221
216
  'color',
222
217
  'description',
223
218
  ]);
224
-
225
- /**
226
- * Wireframe uses a trailing-keyword flag list (§19.5), not key-value
227
- * metadata. This empty registry exists so callers can still pass a
228
- * registry to shared helpers without a special-case.
229
- */
230
- export const WIREFRAME_REGISTRY: ReservedKeyRegistry = staticRegistry([]);
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Make an SVG produced by `@diagrammo/dgmo`'s static `render()` suitable for
3
+ * responsive inline embedding in any host (Obsidian, remark/markdown, web
4
+ * pages):
5
+ *
6
+ * - dgmo renders diagrams inside a fixed export canvas (e.g.
7
+ * `viewBox="0 0 1200 800"`), with content often occupying only a fraction
8
+ * of it. We compute a tight content bounding box from element coordinates
9
+ * and set the root `viewBox` to bbox+padding, so the diagram's intrinsic
10
+ * aspect ratio matches its CONTENT — no dead space above/below or beside it.
11
+ * - Ensure the root `<svg>` has a `viewBox` so it scales responsively.
12
+ * - Strip fixed `width="N"` / `height="N"` so CSS (e.g. `width:100%;
13
+ * height:auto`, or an aspect-ratio derived from the tight viewBox) controls
14
+ * sizing.
15
+ * - Remove any inline `background:` from the root style so the page
16
+ * background shows through.
17
+ *
18
+ * This is intentionally a string transform, not a DOM `getBBox()` step: dgmo
19
+ * can dual-render light/dark SVGs where one is hidden by color-mode CSS, and
20
+ * `getBBox()` returns 0 for the hidden copy. Parsing coordinates from the
21
+ * markup measures both copies reliably and works server-side (Node).
22
+ */
23
+ export function normalizeSvgForEmbed(input: string): string {
24
+ let svg = input;
25
+ const rootMatch = svg.match(/<svg[^>]*>/);
26
+ const rootTag = rootMatch?.[0] ?? '';
27
+ if (rootTag && !rootTag.includes('viewBox')) {
28
+ const wh = rootTag.match(/width="(\d+)"[^>]*height="(\d+)"/);
29
+ if (wh) {
30
+ svg = svg.replace(/<svg/, `<svg viewBox="0 0 ${wh[1]} ${wh[2]}"`);
31
+ }
32
+ }
33
+
34
+ const tight = computeBBox(svg);
35
+ if (tight && tight.width > 0 && tight.height > 0) {
36
+ const pad = 16;
37
+ const vb = `${tight.x - pad} ${tight.y - pad} ${tight.width + pad * 2} ${tight.height + pad * 2}`;
38
+ svg = svg.replace(/(<svg[^>]*?)viewBox="[^"]*"/, `$1viewBox="${vb}"`);
39
+ }
40
+
41
+ svg = svg.replace(/(<svg[^>]*?) width="[^"]*"/g, '$1');
42
+ svg = svg.replace(/(<svg[^>]*?) height="[^"]*"/g, '$1');
43
+ svg = svg.replace(/(<svg[^>]*?style="[^"]*?)background:[^;"]*;?\s*/g, '$1');
44
+ svg = svg.replace(/<svg\s{2,}/g, '<svg ');
45
+ return svg;
46
+ }
47
+
48
+ /**
49
+ * Parse the content bounding box of a normalized embed SVG, if one can be
50
+ * derived. Returns `null` when no usable coordinates are found (e.g. an empty
51
+ * diagram). Useful for hosts that want to set an explicit `aspect-ratio` from
52
+ * the tight viewBox.
53
+ */
54
+ export function getEmbedSvgViewBox(
55
+ svg: string
56
+ ): { x: number; y: number; width: number; height: number } | null {
57
+ const tight = computeBBox(svg);
58
+ if (!tight || tight.width <= 0 || tight.height <= 0) return null;
59
+ const pad = 16;
60
+ return {
61
+ x: tight.x - pad,
62
+ y: tight.y - pad,
63
+ width: tight.width + pad * 2,
64
+ height: tight.height + pad * 2,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Compute an approximate content bounding box from raw element coordinates.
70
+ *
71
+ * This is a regex walk, not a real SVG layout — it ignores `transform`
72
+ * attributes and uses a heuristic for text widths. dgmo's renderers mostly use
73
+ * absolute coordinates within their viewBox, so the approximation is close
74
+ * enough that the rendered output reliably fills the visible area.
75
+ */
76
+ function computeBBox(
77
+ svg: string
78
+ ): { x: number; y: number; width: number; height: number } | null {
79
+ const xs: number[] = [];
80
+ const ys: number[] = [];
81
+
82
+ function push(x: number, y: number): void {
83
+ if (Number.isFinite(x) && Number.isFinite(y)) {
84
+ xs.push(x);
85
+ ys.push(y);
86
+ }
87
+ }
88
+
89
+ function attr(tag: string, name: string): number | null {
90
+ const m = tag.match(new RegExp(`\\b${name}="([^"]*)"`));
91
+ if (!m) return null;
92
+ const n = parseFloat(m[1]!);
93
+ return Number.isFinite(n) ? n : null;
94
+ }
95
+
96
+ // <rect x y width height>
97
+ for (const m of svg.matchAll(/<rect\b[^>]*?\/?>/g)) {
98
+ const tag = m[0];
99
+ const x = attr(tag, 'x');
100
+ const y = attr(tag, 'y');
101
+ const w = attr(tag, 'width');
102
+ const h = attr(tag, 'height');
103
+ if (x !== null && y !== null && w !== null && h !== null) {
104
+ push(x, y);
105
+ push(x + w, y + h);
106
+ }
107
+ }
108
+
109
+ // <line x1 y1 x2 y2>
110
+ for (const m of svg.matchAll(/<line\b[^>]*?\/?>/g)) {
111
+ const tag = m[0];
112
+ const x1 = attr(tag, 'x1');
113
+ const y1 = attr(tag, 'y1');
114
+ const x2 = attr(tag, 'x2');
115
+ const y2 = attr(tag, 'y2');
116
+ if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) {
117
+ push(x1, y1);
118
+ push(x2, y2);
119
+ }
120
+ }
121
+
122
+ // <circle cx cy r>
123
+ for (const m of svg.matchAll(/<circle\b[^>]*?\/?>/g)) {
124
+ const tag = m[0];
125
+ const cx = attr(tag, 'cx');
126
+ const cy = attr(tag, 'cy');
127
+ const r = attr(tag, 'r');
128
+ if (cx !== null && cy !== null && r !== null) {
129
+ push(cx - r, cy - r);
130
+ push(cx + r, cy + r);
131
+ }
132
+ }
133
+
134
+ // <ellipse cx cy rx ry>
135
+ for (const m of svg.matchAll(/<ellipse\b[^>]*?\/?>/g)) {
136
+ const tag = m[0];
137
+ const cx = attr(tag, 'cx');
138
+ const cy = attr(tag, 'cy');
139
+ const rx = attr(tag, 'rx');
140
+ const ry = attr(tag, 'ry');
141
+ if (cx !== null && cy !== null && rx !== null && ry !== null) {
142
+ push(cx - rx, cy - ry);
143
+ push(cx + rx, cy + ry);
144
+ }
145
+ }
146
+
147
+ // <text x y>some content</text>
148
+ // Approximate width: text content length × an empirical font width factor.
149
+ // dgmo uses Inter ~14px by default; ~7px per character is a usable rough
150
+ // estimate that won't drastically under- or over-count.
151
+ for (const m of svg.matchAll(/<text\b([^>]*?)>([\s\S]*?)<\/text>/g)) {
152
+ const tag = `<text${m[1]}>`;
153
+ const text = m[2]!.replace(/<[^>]+>/g, ''); // strip inner tags (tspan, etc.)
154
+ const x = attr(tag, 'x');
155
+ const y = attr(tag, 'y');
156
+ if (x !== null && y !== null) {
157
+ const w = text.length * 7;
158
+ // text-anchor may be start/middle/end; assume worst case (middle).
159
+ push(x - w / 2, y - 14);
160
+ push(x + w / 2, y + 4);
161
+ }
162
+ }
163
+
164
+ // <path d="..."> — pull every coordinate pair out of the d attribute.
165
+ for (const m of svg.matchAll(/<path\b[^>]*?\bd="([^"]+)"/g)) {
166
+ const d = m[1]!;
167
+ const nums = d.match(/-?\d+(?:\.\d+)?/g);
168
+ if (!nums) continue;
169
+ for (let i = 0; i + 1 < nums.length; i += 2) {
170
+ push(parseFloat(nums[i]!), parseFloat(nums[i + 1]!));
171
+ }
172
+ }
173
+
174
+ // <polygon points="x,y x,y ..."> and <polyline>
175
+ for (const m of svg.matchAll(
176
+ /<(?:polygon|polyline)\b[^>]*?\bpoints="([^"]+)"/g
177
+ )) {
178
+ const nums = m[1]!.match(/-?\d+(?:\.\d+)?/g);
179
+ if (!nums) continue;
180
+ for (let i = 0; i + 1 < nums.length; i += 2) {
181
+ push(parseFloat(nums[i]!), parseFloat(nums[i + 1]!));
182
+ }
183
+ }
184
+
185
+ if (xs.length === 0 || ys.length === 0) return null;
186
+
187
+ const minX = Math.min(...xs);
188
+ const maxX = Math.max(...xs);
189
+ const minY = Math.min(...ys);
190
+ const maxY = Math.max(...ys);
191
+
192
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
193
+ }