@diagrammo/dgmo 0.36.0 → 0.37.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/.cursorrules +2 -0
- package/.github/copilot-instructions.md +2 -0
- package/.windsurfrules +2 -0
- package/SKILL.md +2 -0
- package/dist/advanced.cjs +139 -54
- package/dist/advanced.d.cts +12 -7
- package/dist/advanced.d.ts +12 -7
- package/dist/advanced.js +139 -54
- package/dist/auto.cjs +140 -55
- package/dist/auto.js +96 -96
- package/dist/auto.mjs +140 -55
- package/dist/cli.cjs +107 -107
- package/dist/index.cjs +139 -54
- package/dist/index.js +139 -54
- package/docs/ai-integration.md +2 -0
- package/docs/language-reference.md +11 -7
- package/gallery/fixtures/map-coastline.dgmo +2 -2
- package/gallery/fixtures/map-route.dgmo +5 -5
- package/package.json +1 -1
- package/src/infra/parser.ts +95 -38
- package/src/map/layout.ts +91 -7
- package/src/map/parser.ts +51 -29
- package/src/map/renderer.ts +10 -0
- package/src/map/types.ts +9 -8
package/src/infra/parser.ts
CHANGED
|
@@ -78,16 +78,20 @@ const TAG_VALUE_RE = /^(\w[\w\s]+?)\s*$/;
|
|
|
78
78
|
|
|
79
79
|
// Component line. Accepts either a quoted name ("name with | : reserved chars")
|
|
80
80
|
// or a bare name (multi-word allowed; must start with letter/underscore so digit-
|
|
81
|
-
// only or sigil-led lines fall through to other branches).
|
|
82
|
-
// after a `|` separator.
|
|
81
|
+
// only or sigil-led lines fall through to other branches). Metadata follows
|
|
82
|
+
// after a `|` separator (legacy) or as same-line `key: value` (§1.4).
|
|
83
83
|
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
84
|
+
// Named captures: qname (quoted content), uname (bare name), alias (`as <x>`
|
|
85
|
+
// postfix), meta (the metadata tail). qname/uname are mutually exclusive.
|
|
86
|
+
//
|
|
87
|
+
// A component declaration: a quoted display name OR a bare name, an OPTIONAL
|
|
88
|
+
// `as <alias>` postfix (alias mirrors peelAlias: letter-led, ≤12 chars), then
|
|
89
|
+
// optional same-line metadata. The `as` capture is what makes a quoted name +
|
|
90
|
+
// alias parse — `"Web App" as web` — which the bare-name path got for free by
|
|
91
|
+
// sweeping the alias into the lazy name group and peeling it later, but the
|
|
92
|
+
// quoted path could not reach past the closing quote.
|
|
89
93
|
const COMPONENT_RE =
|
|
90
|
-
/^(?:"([^"]+)"|([a-zA-Z_][\w -]*?))\s*((?:\|.*)|(?:[a-zA-Z_]\w*\s*:.*))?$/;
|
|
94
|
+
/^(?:"(?<qname>[^"]+)"|(?<uname>[a-zA-Z_][\w -]*?))(?:\s+as\s+(?<alias>[A-Za-z][A-Za-z0-9_]{0,11}))?\s*(?<meta>(?:\|.*)|(?:[a-zA-Z_]\w*\s*:.*))?$/;
|
|
91
95
|
|
|
92
96
|
// Legacy pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated).
|
|
93
97
|
// Kept for transitional back-compat extraction; new same-line metadata
|
|
@@ -302,6 +306,11 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
302
306
|
let currentGroup: Writable<InfraGroup> | null = null;
|
|
303
307
|
let currentTagGroup: Writable<InfraTagGroup> | null = null;
|
|
304
308
|
let baseIndent = 0; // indent of the current component line
|
|
309
|
+
// The `infra [Title]` declaration is only legal on the first content line.
|
|
310
|
+
// parseFirstLine() must run ONCE, there — otherwise a later bare node line
|
|
311
|
+
// whose name is a chart-type keyword (e.g. an edge-block header `timeline`)
|
|
312
|
+
// is mis-read as a nested chart declaration and the parse aborts.
|
|
313
|
+
let firstLineConsumed = false;
|
|
305
314
|
|
|
306
315
|
function finishCurrentNode() {
|
|
307
316
|
if (!currentNode) return;
|
|
@@ -310,7 +319,12 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
310
319
|
if (existing) {
|
|
311
320
|
const incomingDisplay = displayName(currentNode.label);
|
|
312
321
|
const existingDisplay = displayName(existing.label);
|
|
313
|
-
|
|
322
|
+
// A bare reference line that is a declared alias of `existing` (e.g. an
|
|
323
|
+
// edge-block header `web` for a node `"Web App" as web`) is an intentional
|
|
324
|
+
// pointer, not a near-duplicate name — don't flag it as a case/whitespace
|
|
325
|
+
// typo. Genuine collisions (no matching alias) still warn.
|
|
326
|
+
const isAliasRef = nameAliasMap.get(currentNode.label) === key;
|
|
327
|
+
if (incomingDisplay !== existingDisplay && !isAliasRef) {
|
|
314
328
|
result.diagnostics.push(
|
|
315
329
|
makeDgmoError(
|
|
316
330
|
currentNode.lineNumber,
|
|
@@ -400,20 +414,25 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
400
414
|
finishCurrentNode();
|
|
401
415
|
}
|
|
402
416
|
|
|
403
|
-
// First line: `infra [Title]` or legacy `chart: infra`
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
417
|
+
// First line: `infra [Title]` or legacy `chart: infra` — only ever
|
|
418
|
+
// attempted on the first content line (see firstLineConsumed). A node or
|
|
419
|
+
// alias named like a chart type elsewhere is a normal node, not a redecl.
|
|
420
|
+
if (!firstLineConsumed) {
|
|
421
|
+
firstLineConsumed = true;
|
|
422
|
+
const firstLineResult = parseFirstLine(trimmed);
|
|
423
|
+
if (firstLineResult) {
|
|
424
|
+
if (firstLineResult.chartType !== 'infra') {
|
|
425
|
+
setError(
|
|
426
|
+
lineNumber,
|
|
427
|
+
`Expected chart type 'infra', got '${firstLineResult.chartType}'`
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (firstLineResult.title) {
|
|
431
|
+
result.title = firstLineResult.title;
|
|
432
|
+
result.titleLineNumber = lineNumber;
|
|
433
|
+
}
|
|
434
|
+
continue;
|
|
415
435
|
}
|
|
416
|
-
continue;
|
|
417
436
|
}
|
|
418
437
|
|
|
419
438
|
// direction-tb — bare boolean to switch to top-to-bottom (default is LR)
|
|
@@ -496,18 +515,26 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
496
515
|
finishCurrentNode();
|
|
497
516
|
finishCurrentTagGroup();
|
|
498
517
|
|
|
499
|
-
const
|
|
518
|
+
const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
|
|
519
|
+
const rawName = (qname ?? uname ?? '').trim();
|
|
500
520
|
const peeled = peelInfraDecorations(rawName, lineNumber);
|
|
501
521
|
const name = peeled.label;
|
|
502
|
-
|
|
522
|
+
// Explicit `as` capture wins; fall back to an alias the peeler split out
|
|
523
|
+
// of a bare name (and the quoted-literal `"Foo as bar"` legacy case).
|
|
524
|
+
const alias = asAlias ?? peeled.alias;
|
|
525
|
+
const rest = meta ?? '';
|
|
503
526
|
const { tags } = extractPipeMetadata(rest);
|
|
504
527
|
warnUnknownMetaKeys(
|
|
505
528
|
tags,
|
|
506
529
|
withTagAliases(INFRA_REGISTRY, tagAliasSet),
|
|
507
530
|
(msg) => warn(lineNumber, msg)
|
|
508
531
|
);
|
|
509
|
-
|
|
510
|
-
|
|
532
|
+
// A bare reference line may be an alias for an already-declared node
|
|
533
|
+
// (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
|
|
534
|
+
// through the alias map first so an edge-source/reference merges into
|
|
535
|
+
// that node instead of spawning a duplicate stub.
|
|
536
|
+
const id = nameAliasMap.get(name) ?? nodeId(name);
|
|
537
|
+
if (alias) nameAliasMap.set(alias, id);
|
|
511
538
|
const isEdge = EDGE_NODE_NAMES.has(id.toLowerCase());
|
|
512
539
|
|
|
513
540
|
currentNode = {
|
|
@@ -609,18 +636,24 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
609
636
|
const compMatch = trimmed.match(COMPONENT_RE);
|
|
610
637
|
if (compMatch) {
|
|
611
638
|
finishCurrentTagGroup();
|
|
612
|
-
const
|
|
639
|
+
const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
|
|
640
|
+
const rawName = (qname ?? uname ?? '').trim();
|
|
613
641
|
const peeled = peelInfraDecorations(rawName, lineNumber);
|
|
614
642
|
const name = peeled.label;
|
|
615
|
-
const
|
|
643
|
+
const alias = asAlias ?? peeled.alias;
|
|
644
|
+
const rest = meta ?? '';
|
|
616
645
|
const { tags: nodeTags } = extractPipeMetadata(rest);
|
|
617
646
|
warnUnknownMetaKeys(
|
|
618
647
|
nodeTags,
|
|
619
648
|
withTagAliases(INFRA_REGISTRY, tagAliasSet),
|
|
620
649
|
(msg) => warn(lineNumber, msg)
|
|
621
650
|
);
|
|
622
|
-
|
|
623
|
-
|
|
651
|
+
// A bare reference line may be an alias for an already-declared node
|
|
652
|
+
// (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
|
|
653
|
+
// through the alias map first so an edge-source/reference merges into
|
|
654
|
+
// that node instead of spawning a duplicate stub.
|
|
655
|
+
const id = nameAliasMap.get(name) ?? nodeId(name);
|
|
656
|
+
if (alias) nameAliasMap.set(alias, id);
|
|
624
657
|
// Cascade group metadata into node tags; node-level metadata overrides
|
|
625
658
|
const tags: Record<string, string> = currentGroup.metadata
|
|
626
659
|
? { ...currentGroup.metadata, ...nodeTags }
|
|
@@ -859,6 +892,18 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
859
892
|
continue;
|
|
860
893
|
}
|
|
861
894
|
|
|
895
|
+
// Indented tag assignment — `t: Edge` on its own line, the indented twin
|
|
896
|
+
// of same-line `Name t: Edge`. A tag alias is neither a behavior property
|
|
897
|
+
// nor 'description', so without this it falls through to the unknown-key
|
|
898
|
+
// path and is mis-collected as description text, silently dropping the
|
|
899
|
+
// node's tier. currentNode.tags is the same object held in
|
|
900
|
+
// nodeMutableTags, so a direct assign is enough.
|
|
901
|
+
if (currentNode && tagAliasSet.has(normalizeName(key))) {
|
|
902
|
+
const mut = nodeMutableTags.get(currentNode.id);
|
|
903
|
+
if (mut) mut[tagAttrKey(key)] = rawVal;
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
|
|
862
907
|
// Unknown keys: decide between property typo warning vs description collection.
|
|
863
908
|
// Heuristic: if the key looks like a plausible property (alphanumeric-hyphen, close
|
|
864
909
|
// match to a known key, or the value looks numeric/percentage), warn as typo.
|
|
@@ -947,18 +992,24 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
947
992
|
|
|
948
993
|
const compMatch = trimmed.match(COMPONENT_RE);
|
|
949
994
|
if (compMatch) {
|
|
950
|
-
const
|
|
995
|
+
const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
|
|
996
|
+
const rawName = (qname ?? uname ?? '').trim();
|
|
951
997
|
const peeled = peelInfraDecorations(rawName, lineNumber);
|
|
952
998
|
const name = peeled.label;
|
|
953
|
-
const
|
|
999
|
+
const alias = asAlias ?? peeled.alias;
|
|
1000
|
+
const rest = meta ?? '';
|
|
954
1001
|
const { tags: nodeTags } = extractPipeMetadata(rest);
|
|
955
1002
|
warnUnknownMetaKeys(
|
|
956
1003
|
nodeTags,
|
|
957
1004
|
withTagAliases(INFRA_REGISTRY, tagAliasSet),
|
|
958
1005
|
(msg) => warn(lineNumber, msg)
|
|
959
1006
|
);
|
|
960
|
-
|
|
961
|
-
|
|
1007
|
+
// A bare reference line may be an alias for an already-declared node
|
|
1008
|
+
// (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
|
|
1009
|
+
// through the alias map first so an edge-source/reference merges into
|
|
1010
|
+
// that node instead of spawning a duplicate stub.
|
|
1011
|
+
const id = nameAliasMap.get(name) ?? nodeId(name);
|
|
1012
|
+
if (alias) nameAliasMap.set(alias, id);
|
|
962
1013
|
const tags: Record<string, string> = currentGroup.metadata
|
|
963
1014
|
? { ...currentGroup.metadata, ...nodeTags }
|
|
964
1015
|
: nodeTags;
|
|
@@ -986,18 +1037,24 @@ export function parseInfra(content: string): ParsedInfra {
|
|
|
986
1037
|
finishCurrentTagGroup();
|
|
987
1038
|
currentGroup = null;
|
|
988
1039
|
|
|
989
|
-
const
|
|
1040
|
+
const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
|
|
1041
|
+
const rawName = (qname ?? uname ?? '').trim();
|
|
990
1042
|
const peeled = peelInfraDecorations(rawName, lineNumber);
|
|
991
1043
|
const name = peeled.label;
|
|
992
|
-
const
|
|
1044
|
+
const alias = asAlias ?? peeled.alias;
|
|
1045
|
+
const rest = meta ?? '';
|
|
993
1046
|
const { tags } = extractPipeMetadata(rest);
|
|
994
1047
|
warnUnknownMetaKeys(
|
|
995
1048
|
tags,
|
|
996
1049
|
withTagAliases(INFRA_REGISTRY, tagAliasSet),
|
|
997
1050
|
(msg) => warn(lineNumber, msg)
|
|
998
1051
|
);
|
|
999
|
-
|
|
1000
|
-
|
|
1052
|
+
// A bare reference line may be an alias for an already-declared node
|
|
1053
|
+
// (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
|
|
1054
|
+
// through the alias map first so an edge-source/reference merges into
|
|
1055
|
+
// that node instead of spawning a duplicate stub.
|
|
1056
|
+
const id = nameAliasMap.get(name) ?? nodeId(name);
|
|
1057
|
+
if (alias) nameAliasMap.set(alias, id);
|
|
1001
1058
|
|
|
1002
1059
|
currentNode = {
|
|
1003
1060
|
id,
|
package/src/map/layout.ts
CHANGED
|
@@ -442,6 +442,10 @@ export interface MapLayoutLeg {
|
|
|
442
442
|
* (§24B.6). Omitted when the leg carries no tag. */
|
|
443
443
|
readonly tags?: Readonly<Record<string, string>>;
|
|
444
444
|
readonly label?: string;
|
|
445
|
+
/** The leg's numeric weight (the `value:` metadata) when present and positive.
|
|
446
|
+
* Drives {@link width}, but kept here verbatim so the renderer can surface it
|
|
447
|
+
* on hover (a `<title>` tooltip) — the width alone is lossy. */
|
|
448
|
+
readonly value?: number;
|
|
445
449
|
readonly labelX?: number;
|
|
446
450
|
readonly labelY?: number;
|
|
447
451
|
/** Text colour for the label — contrast-picked against the background fill the
|
|
@@ -3027,13 +3031,15 @@ export function layoutMap(
|
|
|
3027
3031
|
leg.label !== undefined
|
|
3028
3032
|
? labelOnFill(fillAt(bow.labelX, bow.labelY))
|
|
3029
3033
|
: undefined;
|
|
3034
|
+
const routeVal = Number(leg.value);
|
|
3030
3035
|
legs.push({
|
|
3031
3036
|
d: legPath(a, b, bow.curved, bow.offset),
|
|
3032
|
-
width: routeWidthFor(
|
|
3037
|
+
width: routeWidthFor(routeVal),
|
|
3033
3038
|
color: lineColor(leg.tags) ?? mix(palette.text, palette.bg, 72),
|
|
3034
3039
|
arrow: true,
|
|
3035
3040
|
fromId: leg.fromId,
|
|
3036
3041
|
toId: leg.toId,
|
|
3042
|
+
...(Number.isFinite(routeVal) && routeVal > 0 && { value: routeVal }),
|
|
3037
3043
|
...(Object.keys(leg.tags).length > 0 && { tags: leg.tags }),
|
|
3038
3044
|
lineNumber: leg.lineNumber,
|
|
3039
3045
|
...(leg.label !== undefined && {
|
|
@@ -3087,6 +3093,7 @@ export function layoutMap(
|
|
|
3087
3093
|
e.label !== undefined
|
|
3088
3094
|
? labelOnFill(fillAt(bow.labelX, bow.labelY))
|
|
3089
3095
|
: undefined;
|
|
3096
|
+
const edgeVal = Number(e.meta['value']);
|
|
3090
3097
|
legs.push({
|
|
3091
3098
|
d: legPath(a, b, bow.curved, bow.offset),
|
|
3092
3099
|
width: widthFor(e),
|
|
@@ -3094,6 +3101,7 @@ export function layoutMap(
|
|
|
3094
3101
|
arrow: e.directed,
|
|
3095
3102
|
fromId: e.fromId,
|
|
3096
3103
|
toId: e.toId,
|
|
3104
|
+
...(Number.isFinite(edgeVal) && edgeVal > 0 && { value: edgeVal }),
|
|
3097
3105
|
...(Object.keys(e.tags).length > 0 && { tags: e.tags }),
|
|
3098
3106
|
lineNumber: e.lineNumber,
|
|
3099
3107
|
...(e.label !== undefined && {
|
|
@@ -3436,6 +3444,49 @@ export function layoutMap(
|
|
|
3436
3444
|
const SHORT_HOP_MAX = Math.max(46, Math.min(width, height) * 0.11);
|
|
3437
3445
|
const STEP = 4; // px probe granularity walking out of the region
|
|
3438
3446
|
const HOP_GAP = 9; // px clearance between region edge and chip
|
|
3447
|
+
// How OPEN is the space a chip projects into? Measured on the chip's OUTWARD
|
|
3448
|
+
// hemisphere (the side facing away from its region): cast a fan of rays and
|
|
3449
|
+
// return the distance to the nearest land or canvas edge. A chip wedged in a
|
|
3450
|
+
// crowded inlet (the Gulf of Mexico, hemmed by Florida + the Caribbean)
|
|
3451
|
+
// scores low; one sitting in open water (the empty Pacific off Mexico's west
|
|
3452
|
+
// coast) saturates the cap. Callout scoring MAXIMISES this, using leader
|
|
3453
|
+
// length only as a tie-break — so a region leaders toward the open sea, not
|
|
3454
|
+
// into whichever coast happens to be the shortest hop away. We only probe the
|
|
3455
|
+
// outward hemisphere because the inward side always hits the region's own
|
|
3456
|
+
// fill immediately (the chip hugs its border), which would flatten every spot.
|
|
3457
|
+
const CLEAR_STEP = 6; // px granularity of the openness probe
|
|
3458
|
+
const CLEAR_MAX = SHORT_HOP_MAX * 3; // saturation — beyond this it's "open"
|
|
3459
|
+
const CLEAR_TOL = CLEAR_STEP; // openness within a probe step counts as a tie
|
|
3460
|
+
const clearanceAt = (
|
|
3461
|
+
px: number,
|
|
3462
|
+
py: number,
|
|
3463
|
+
ox: number, // outward unit dir (away from the region)
|
|
3464
|
+
oy: number
|
|
3465
|
+
): number => {
|
|
3466
|
+
const baseAng = Math.atan2(oy, ox);
|
|
3467
|
+
let minClear = CLEAR_MAX;
|
|
3468
|
+
// 5 rays spanning the outward hemisphere (baseAng ± 90° in 45° steps).
|
|
3469
|
+
for (let k = -2; k <= 2; k++) {
|
|
3470
|
+
const a = baseAng + (k * Math.PI) / 4;
|
|
3471
|
+
const rx = Math.cos(a);
|
|
3472
|
+
const ry = Math.sin(a);
|
|
3473
|
+
let d = CLEAR_STEP;
|
|
3474
|
+
for (; d <= CLEAR_MAX; d += CLEAR_STEP) {
|
|
3475
|
+
const qx = px + rx * d;
|
|
3476
|
+
const qy = py + ry * d;
|
|
3477
|
+
if (
|
|
3478
|
+
qx < FIT_PAD ||
|
|
3479
|
+
qy < topPad ||
|
|
3480
|
+
qx > width - FIT_PAD ||
|
|
3481
|
+
qy > height - FIT_PAD
|
|
3482
|
+
)
|
|
3483
|
+
break; // ran into the frame — that edge bounds the openness
|
|
3484
|
+
if (fillAt(qx, qy) !== water) break; // hit land of any kind
|
|
3485
|
+
}
|
|
3486
|
+
if (d < minClear) minClear = d;
|
|
3487
|
+
}
|
|
3488
|
+
return minClear;
|
|
3489
|
+
};
|
|
3439
3490
|
// Try to seat `name`(+`value`) as a short-hop chip for a region centred at
|
|
3440
3491
|
// (cx,cy) on fill `fill`. Returns the placement (chip rect + leader) or null.
|
|
3441
3492
|
const tryShortHopCallout = (
|
|
@@ -3455,11 +3506,19 @@ export function layoutMap(
|
|
|
3455
3506
|
} | null => {
|
|
3456
3507
|
const chipW = stackW(name, value, font);
|
|
3457
3508
|
const chipH = stackH(value !== undefined, font);
|
|
3509
|
+
// Unit vectors — 4 cardinals + 4 diagonals. Diagonals matter: open water
|
|
3510
|
+
// often sits off-axis (Mexico's empty sea is WSW), and a pure-cardinal
|
|
3511
|
+
// scheme can only aim due-N/S/E/W. Magnitude 1 keeps each walk step = STEP px.
|
|
3512
|
+
const D = Math.SQRT1_2; // 0.7071…
|
|
3458
3513
|
const dirs: Array<[number, number]> = [
|
|
3459
|
-
[0, -1], // north
|
|
3460
|
-
[0, 1], // south
|
|
3461
|
-
[1, 0], // east
|
|
3462
|
-
[-1, 0], // west
|
|
3514
|
+
[0, -1], // north
|
|
3515
|
+
[0, 1], // south
|
|
3516
|
+
[1, 0], // east
|
|
3517
|
+
[-1, 0], // west
|
|
3518
|
+
[D, -D], // NE
|
|
3519
|
+
[D, D], // SE
|
|
3520
|
+
[-D, -D], // NW
|
|
3521
|
+
[-D, D], // SW
|
|
3463
3522
|
];
|
|
3464
3523
|
let best: {
|
|
3465
3524
|
x: number;
|
|
@@ -3467,6 +3526,7 @@ export function layoutMap(
|
|
|
3467
3526
|
rect: LabelRect;
|
|
3468
3527
|
leader: [number, number, number, number];
|
|
3469
3528
|
len: number;
|
|
3529
|
+
clear: number;
|
|
3470
3530
|
} | null = null;
|
|
3471
3531
|
for (const [dx, dy] of dirs) {
|
|
3472
3532
|
// Walk from the centroid outward until we leave the region's own fill —
|
|
@@ -3533,13 +3593,23 @@ export function layoutMap(
|
|
|
3533
3593
|
)
|
|
3534
3594
|
)
|
|
3535
3595
|
continue;
|
|
3536
|
-
|
|
3596
|
+
// Prefer the MOST OPEN spot; fall back to the shortest leader only when
|
|
3597
|
+
// two candidates are about equally open (e.g. several open-water spots
|
|
3598
|
+
// that all saturate the clearance cap). This is what redirects a label
|
|
3599
|
+
// away from a cramped-but-close coast toward the empty sea.
|
|
3600
|
+
const clear = clearanceAt(ccx, ccy, dx, dy);
|
|
3601
|
+
const better =
|
|
3602
|
+
best === null ||
|
|
3603
|
+
clear > best.clear + CLEAR_TOL ||
|
|
3604
|
+
(clear >= best.clear - CLEAR_TOL && len < best.len);
|
|
3605
|
+
if (better)
|
|
3537
3606
|
best = {
|
|
3538
3607
|
x: ccx,
|
|
3539
3608
|
y: ccy,
|
|
3540
3609
|
rect,
|
|
3541
3610
|
leader: [cx, cy, innerX, innerY],
|
|
3542
3611
|
len,
|
|
3612
|
+
clear,
|
|
3543
3613
|
};
|
|
3544
3614
|
}
|
|
3545
3615
|
return best
|
|
@@ -4599,6 +4669,20 @@ export function layoutMap(
|
|
|
4599
4669
|
const nx = -dy / len;
|
|
4600
4670
|
const ny = dx / len; // unit normal to the chord
|
|
4601
4671
|
const perp = labelH + 2;
|
|
4672
|
+
// A label rides ON its line by default (off=0 first) — a hairline under
|
|
4673
|
+
// the text reads fine and looks intentional. But a HEAVY weighted leg
|
|
4674
|
+
// (value→width up to W_MAX) draws a thick dark stroke, and text centred on
|
|
4675
|
+
// it is unreadable (dark-on-dark, no contrast). For those, lead with a
|
|
4676
|
+
// perpendicular hop that clears the stroke's half-width + the label's own
|
|
4677
|
+
// half-height + a gap, so the text sits just BESIDE the line on open
|
|
4678
|
+
// ground (both sides tried; the first clean one — typically the open-water
|
|
4679
|
+
// side — wins). Thin legs keep the ride-on default untouched.
|
|
4680
|
+
const stroke = lg.width ?? W_MIN;
|
|
4681
|
+
const clearW = stroke / 2 + labelH / 2 + 3;
|
|
4682
|
+
const offList =
|
|
4683
|
+
stroke >= 4
|
|
4684
|
+
? [clearW, -clearW, clearW + perp, -(clearW + perp), 0]
|
|
4685
|
+
: [0, perp, -perp, 2 * perp, -2 * perp];
|
|
4602
4686
|
// Walk the ACTUAL DRAWN PATH, not the chord: parse the leg's `d` (the
|
|
4603
4687
|
// trimmed `M…Q…` arc or `M…L…` line) and sample the curve at each t, so a
|
|
4604
4688
|
// label rides its own bowed line instead of floating at the chord midpoint
|
|
@@ -4627,7 +4711,7 @@ export function layoutMap(
|
|
|
4627
4711
|
// hop — growing — so a label on a SHORT leg between two close dots (where
|
|
4628
4712
|
// every on-line slot straddles an endpoint) can still escape above or below.
|
|
4629
4713
|
const candidates: Array<[number, number]> = [];
|
|
4630
|
-
for (const off of
|
|
4714
|
+
for (const off of offList)
|
|
4631
4715
|
for (const t of T_LIST) {
|
|
4632
4716
|
const [bx, by] = pointAt(t);
|
|
4633
4717
|
candidates.push([bx + nx * off, by + ny * off - 4]);
|
package/src/map/parser.ts
CHANGED
|
@@ -186,8 +186,8 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
|
|
|
186
186
|
}
|
|
187
187
|
// (1a) Indented child of an open route → a leg (an edge from the prev stop).
|
|
188
188
|
if (open.route && indent > open.route.indent) {
|
|
189
|
-
const leg = parseLeg(trimmed, lineNumber
|
|
190
|
-
(open.route.route.legs as MapRouteLeg[]).push(leg);
|
|
189
|
+
const leg = parseLeg(trimmed, lineNumber);
|
|
190
|
+
if (leg) (open.route.route.legs as MapRouteLeg[]).push(leg);
|
|
191
191
|
continue;
|
|
192
192
|
}
|
|
193
193
|
// (1b) Indented child of an open POI → hub edge or extra metadata.
|
|
@@ -217,6 +217,18 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
|
|
|
217
217
|
Object.assign(open.poi.poi.meta as Record<string, string>, split.meta);
|
|
218
218
|
continue;
|
|
219
219
|
}
|
|
220
|
+
// A named child line with no arrow is a hub edge missing its glyph (the
|
|
221
|
+
// POI-hub mirror of a malformed route leg). Error here rather than letting
|
|
222
|
+
// it fall through and be silently reinterpreted as a top-level region.
|
|
223
|
+
if (split.name) {
|
|
224
|
+
const src =
|
|
225
|
+
open.poi.poi.alias ?? poiName(open.poi.poi.pos) ?? 'the poi';
|
|
226
|
+
pushError(
|
|
227
|
+
lineNumber,
|
|
228
|
+
`Malformed hub edge: "${trimmed}" — an indented line under a poi is an edge from "${src}" and needs an arrow glyph (\`-> dest\`, \`~> dest\`, or labeled \`-label-> dest\`). For a standalone place, declare it at the top level. (§24B.5)`
|
|
229
|
+
);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
220
232
|
// not a recognized child → fall through after closing the POI
|
|
221
233
|
}
|
|
222
234
|
|
|
@@ -510,17 +522,20 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
|
|
|
510
522
|
const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
|
|
511
523
|
const originLabel = meta['label'];
|
|
512
524
|
const originValue = meta['value'];
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
525
|
+
// `style: arc` on the route header was removed (§24B.6): leg shape comes
|
|
526
|
+
// solely from each leg's own arrow glyph (`-…->` straight, `~…~>` arc).
|
|
527
|
+
if (meta['style'] !== undefined) {
|
|
528
|
+
pushError(
|
|
529
|
+
line,
|
|
530
|
+
'route header no longer takes `style:` — set leg shape with the arrow glyph instead (`~…~>` for an arc leg, `-…->` for a straight one). (§24B.6)'
|
|
531
|
+
);
|
|
532
|
+
}
|
|
517
533
|
const route: Writable<MapRoute> = {
|
|
518
534
|
origin: pos,
|
|
519
535
|
...(split.alias !== undefined && { originAlias: split.alias }),
|
|
520
536
|
...(originLabel !== undefined && { originLabel }),
|
|
521
537
|
...(originValue !== undefined && { originValue }),
|
|
522
538
|
originTags: tags,
|
|
523
|
-
style,
|
|
524
539
|
legs: [],
|
|
525
540
|
lineNumber: line,
|
|
526
541
|
};
|
|
@@ -528,25 +543,34 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
|
|
|
528
543
|
open.route = { route, indent };
|
|
529
544
|
}
|
|
530
545
|
|
|
531
|
-
/** Parse one route body line into a leg:
|
|
532
|
-
*
|
|
533
|
-
* a tag colours the LINE (§24B.6);
|
|
534
|
-
*
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
headerStyle: 'straight' | 'arc'
|
|
539
|
-
): MapRouteLeg {
|
|
540
|
-
let arrowStyle: 'straight' | 'arc' = 'straight';
|
|
541
|
-
let label: string | undefined;
|
|
542
|
-
let rest = trimmed;
|
|
546
|
+
/** Parse one route body line into a leg: `<arrow> <destination> [keys]`. The
|
|
547
|
+
* arrow is REQUIRED and gives the leg label + shape (`-…->` straight, `~…~>`
|
|
548
|
+
* arc); `value:` is leg thickness; a tag colours the LINE (§24B.6);
|
|
549
|
+
* `label:`/`as` name the destination stop. A bare destination with no arrow,
|
|
550
|
+
* or an undirected (`--`/`~~`) glyph, is a parse error — a voyage always flows
|
|
551
|
+
* from the previous stop to the next (§24B.6). */
|
|
552
|
+
function parseLeg(trimmed: string, line: number): MapRouteLeg | null {
|
|
543
553
|
const m = trimmed.match(LEG_ARROW_RE);
|
|
544
|
-
if (m) {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
554
|
+
if (!m) {
|
|
555
|
+
pushError(
|
|
556
|
+
line,
|
|
557
|
+
`Malformed route leg: "${trimmed}" — a leg needs an arrow glyph (\`-> dest\`, \`~> dest\`, or labeled \`-label-> dest\` / \`~label~> dest\`). (§24B.6)`
|
|
558
|
+
);
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
const arr = classifyArrow(m[1]!, line);
|
|
562
|
+
// A route leg is always directional (prev stop → next), so the undirected
|
|
563
|
+
// `--`/`~~` glyphs are meaningless here (they stay valid on free-form edges).
|
|
564
|
+
if (!arr.directed) {
|
|
565
|
+
pushError(
|
|
566
|
+
line,
|
|
567
|
+
`A route leg is directional — "${trimmed}" uses an undirected glyph; use \`-> \` (straight) or \`~> \` (arc). (§24B.6)`
|
|
568
|
+
);
|
|
569
|
+
return null;
|
|
549
570
|
}
|
|
571
|
+
const arrowStyle: 'straight' | 'arc' = arr.style;
|
|
572
|
+
const label: string | undefined = arr.label;
|
|
573
|
+
const rest = m[2]!;
|
|
550
574
|
const split = splitNameAndMeta(
|
|
551
575
|
rest,
|
|
552
576
|
registry(),
|
|
@@ -562,13 +586,11 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
|
|
|
562
586
|
const { tags, meta } = partitionMeta(split.meta, tagGroupNames());
|
|
563
587
|
const value = meta['value'];
|
|
564
588
|
const destLabel = meta['label'];
|
|
565
|
-
// Leg shape comes
|
|
566
|
-
//
|
|
567
|
-
const style: 'straight' | 'arc' =
|
|
568
|
-
arrowStyle === 'arc' || headerStyle === 'arc' ? 'arc' : 'straight';
|
|
589
|
+
// Leg shape comes solely from the leg's own arrow glyph (§24B.6) — the route
|
|
590
|
+
// header `style:` was removed.
|
|
569
591
|
return {
|
|
570
592
|
...(label !== undefined && { label }),
|
|
571
|
-
style,
|
|
593
|
+
style: arrowStyle,
|
|
572
594
|
...(value !== undefined && { value }),
|
|
573
595
|
dest: pos,
|
|
574
596
|
...(split.alias !== undefined && { destAlias: split.alias }),
|
package/src/map/renderer.ts
CHANGED
|
@@ -973,6 +973,16 @@ export function renderMap(
|
|
|
973
973
|
// A 0-width invisible leg path is hard to hit; pointer-events on the visible
|
|
974
974
|
// stroke is enough for the line widths legs use.
|
|
975
975
|
wireSync(p, leg.lineNumber);
|
|
976
|
+
// Surface the leg's weight on hover. A native <title> gives a tooltip on the
|
|
977
|
+
// visible stroke in every surface (app, exported SVG, Obsidian, web) with no
|
|
978
|
+
// viewer code; `data-value` is also emitted so an app overlay can style it
|
|
979
|
+
// later. The label (when any) prefixes the number for context ("fiber: 28").
|
|
980
|
+
if (leg.value !== undefined) {
|
|
981
|
+
p.attr('data-value', leg.value);
|
|
982
|
+
p.append('title').text(
|
|
983
|
+
leg.label !== undefined ? `${leg.label}: ${leg.value}` : `${leg.value}`
|
|
984
|
+
);
|
|
985
|
+
}
|
|
976
986
|
if (arrowTri) {
|
|
977
987
|
// Filled arrowhead at the true endpoint — a flat triangle whose base sits
|
|
978
988
|
// where the trimmed stroke ends, so the line meets it cleanly with no
|
package/src/map/types.ts
CHANGED
|
@@ -110,12 +110,13 @@ export interface MapPoi {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** One leg of a route (§24B.6): an edge from the previous stop to `dest`. Reuses
|
|
113
|
-
* the edge arrow idiom — in-arrow text = leg label,
|
|
114
|
-
*
|
|
115
|
-
* the LINE (§24B.6); `label:`/`as` still
|
|
113
|
+
* the indented edge arrow idiom (same as sitemap) — in-arrow text = leg label,
|
|
114
|
+
* `value:` = leg thickness, the arrow glyph = shape (`-…->` straight, `~…~>`
|
|
115
|
+
* arc). A tag on the leg line colours the LINE (§24B.6); `label:`/`as` still
|
|
116
|
+
* name the DESTINATION stop. The arrow is required — a bare destination errors. */
|
|
116
117
|
export interface MapRouteLeg {
|
|
117
118
|
readonly label?: string; // in-arrow leg label
|
|
118
|
-
readonly style: 'straight' | 'arc';
|
|
119
|
+
readonly style: 'straight' | 'arc'; // from the leg's own arrow glyph
|
|
119
120
|
readonly value?: string; // leg thickness (numeric string, like an edge)
|
|
120
121
|
readonly dest: PoiPos;
|
|
121
122
|
readonly destAlias?: string;
|
|
@@ -126,16 +127,16 @@ export interface MapRouteLeg {
|
|
|
126
127
|
readonly lineNumber: number;
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
/** An ordered, auto-numbered route (§24B.6): `route <origin
|
|
130
|
-
*
|
|
131
|
-
*
|
|
130
|
+
/** An ordered, auto-numbered route (§24B.6): `route <origin>` + a sequence of
|
|
131
|
+
* indented arrow legs, each continuing from the previous stop. Leg shape is
|
|
132
|
+
* per-leg (the arrow glyph) — there is no header-level shape option. Repeat the
|
|
133
|
+
* origin as a leg's destination to close a loop. */
|
|
132
134
|
export interface MapRoute {
|
|
133
135
|
readonly origin: PoiPos;
|
|
134
136
|
readonly originAlias?: string;
|
|
135
137
|
readonly originLabel?: string;
|
|
136
138
|
readonly originValue?: string; // header value → origin marker size
|
|
137
139
|
readonly originTags: Readonly<Record<string, string>>;
|
|
138
|
-
readonly style: 'straight' | 'arc'; // header default leg shape
|
|
139
140
|
readonly legs: readonly MapRouteLeg[];
|
|
140
141
|
readonly lineNumber: number;
|
|
141
142
|
}
|