@bendyline/squisq 1.4.1 → 1.5.1
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 +14 -1
- package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
- package/dist/{Doc-BeSeH2iX.d.ts → Doc-BBVGq1_9.d.ts} +922 -273
- package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +11 -4
- package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
- package/dist/chunk-2OIBZYKP.js.map +1 -0
- package/dist/chunk-2UQOHYE2.js +45 -0
- package/dist/chunk-2UQOHYE2.js.map +1 -0
- package/dist/chunk-6ATE2PSM.js +344 -0
- package/dist/chunk-6ATE2PSM.js.map +1 -0
- package/dist/{chunk-57CVQCFX.js → chunk-A4O7GIWE.js} +74 -23
- package/dist/chunk-A4O7GIWE.js.map +1 -0
- package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
- package/dist/chunk-DLXZMS5K.js.map +1 -0
- package/dist/chunk-ET53IIEP.js +1 -0
- package/dist/chunk-ET53IIEP.js.map +1 -0
- package/dist/chunk-HWVFJAAH.js +362 -0
- package/dist/chunk-HWVFJAAH.js.map +1 -0
- package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
- package/dist/chunk-IMSCRLLV.js.map +1 -0
- package/dist/{chunk-B4EEON3N.js → chunk-KKNUBQ6Y.js} +536 -103
- package/dist/chunk-KKNUBQ6Y.js.map +1 -0
- package/dist/chunk-LDQ2HJIX.js +596 -0
- package/dist/chunk-LDQ2HJIX.js.map +1 -0
- package/dist/chunk-LH7I6SH7.js +7208 -0
- package/dist/chunk-LH7I6SH7.js.map +1 -0
- package/dist/chunk-PGGGKYWG.js +1 -0
- package/dist/chunk-PGGGKYWG.js.map +1 -0
- package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
- package/dist/chunk-Q3ROPT5H.js.map +1 -0
- package/dist/chunk-QE7TWGVT.js +178 -0
- package/dist/chunk-QE7TWGVT.js.map +1 -0
- package/dist/chunk-RUDYOTA6.js +345 -0
- package/dist/chunk-RUDYOTA6.js.map +1 -0
- package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
- package/dist/chunk-T5UK6YOB.js.map +1 -0
- package/dist/{chunk-3K5OG6KI.js → chunk-TEEEILMP.js} +412 -186
- package/dist/chunk-TEEEILMP.js.map +1 -0
- package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
- package/dist/chunk-TQWLI6S2.js.map +1 -0
- package/dist/{chunk-WMOI7FN3.js → chunk-UY7KGQ5R.js} +12 -2
- package/dist/{chunk-WMOI7FN3.js.map → chunk-UY7KGQ5R.js.map} +1 -1
- package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
- package/dist/chunk-VWUFZ6ZG.js.map +1 -0
- package/dist/chunk-ZLQKNOZQ.js +1 -0
- package/dist/chunk-ZLQKNOZQ.js.map +1 -0
- package/dist/doc/index.d.ts +5 -5
- package/dist/doc/index.js +135 -8
- package/dist/generate/index.d.ts +3 -74
- package/dist/generate/index.js +2 -7
- package/dist/icons/index.d.ts +3 -26
- package/dist/icons/index.js +13 -0
- package/dist/icons/inlineIconMarker.d.ts +1 -0
- package/dist/icons/inlineIconMarker.js +15 -0
- package/dist/icons/inlineIconMarker.js.map +1 -0
- package/dist/imageEdit/index.d.ts +12 -6
- package/dist/imageEdit/index.js +4 -1
- package/dist/index.d.ts +14 -11
- package/dist/index.js +265 -37
- package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
- package/dist/jsonForm/index.d.ts +42 -1
- package/dist/jsonForm/index.js +9 -1
- package/dist/markdown/index.d.ts +228 -3
- package/dist/markdown/index.js +42 -7
- package/dist/recommend/index.d.ts +1 -1
- package/dist/recommend/index.js +3 -2
- package/dist/schemas/index.d.ts +28 -8
- package/dist/schemas/index.js +36 -8
- package/dist/spatial/index.d.ts +9 -4
- package/dist/spatial/index.js +1 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.js +1 -1
- package/dist/story/index.d.ts +1021 -41
- package/dist/story/index.js +135 -8
- package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
- package/dist/timing/index.js +3 -5
- package/dist/transform/index.d.ts +48 -10
- package/dist/transform/index.js +17 -481
- package/dist/transform/index.js.map +1 -1
- package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
- package/dist/versions/index.d.ts +1 -1
- package/package.json +6 -1
- package/src/__tests__/animationUtils.test.ts +40 -0
- package/src/__tests__/annotationCoercion.test.ts +243 -0
- package/src/__tests__/applyRenderStyle.test.ts +146 -0
- package/src/__tests__/atmosphereLayers.test.ts +108 -0
- package/src/__tests__/attrTokens.test.ts +162 -0
- package/src/__tests__/contentExtractor.test.ts +60 -0
- package/src/__tests__/coverBlock.test.ts +46 -1
- package/src/__tests__/customTemplateSample.test.ts +144 -0
- package/src/__tests__/customTemplatesFrontmatter.test.ts +236 -0
- package/src/__tests__/customThemesFrontmatter.test.ts +145 -0
- package/src/__tests__/diagramBlock.test.ts +122 -0
- package/src/__tests__/diagramLayout.test.ts +134 -0
- package/src/__tests__/drawing.test.ts +109 -0
- package/src/__tests__/drawingBlock.test.ts +124 -0
- package/src/__tests__/drawingLayout.test.ts +171 -0
- package/src/__tests__/geohash.test.ts +6 -5
- package/src/__tests__/getLayers.test.ts +6 -2
- package/src/__tests__/getLayersFallback.test.ts +103 -0
- package/src/__tests__/imageEditExportSvg.test.ts +70 -0
- package/src/__tests__/imageEditVersions.test.ts +72 -0
- package/src/__tests__/inlineIconMarker.test.ts +36 -0
- package/src/__tests__/inputDescriptors.test.ts +162 -0
- package/src/__tests__/jsonForm.tokens.test.ts +84 -0
- package/src/__tests__/layoutBlock.test.ts +83 -0
- package/src/__tests__/layoutLayout.test.ts +169 -0
- package/src/__tests__/markdown.test.ts +87 -0
- package/src/__tests__/markdownSanitize.test.ts +79 -0
- package/src/__tests__/markdownToDoc.test.ts +391 -2
- package/src/__tests__/mediaAnnotations.test.ts +91 -0
- package/src/__tests__/mediaSchedule.test.ts +118 -0
- package/src/__tests__/pandocAttrParse.test.ts +187 -0
- package/src/__tests__/resolveDocTheme.test.ts +55 -0
- package/src/__tests__/rootExports.test.ts +43 -0
- package/src/__tests__/shapeGeometry.test.ts +89 -0
- package/src/__tests__/shapeVocabulary.test.ts +133 -0
- package/src/__tests__/structuredData.test.ts +249 -0
- package/src/__tests__/templateAnnotationParse.test.ts +108 -0
- package/src/__tests__/templateMetadata.test.ts +31 -0
- package/src/__tests__/templates.test.ts +257 -4
- package/src/__tests__/themeCompile.test.ts +42 -0
- package/src/__tests__/themeValidator.test.ts +6 -0
- package/src/__tests__/transformV2.test.ts +137 -0
- package/src/__tests__/transitionNormalize.test.ts +61 -0
- package/src/__tests__/validateDoc.test.ts +260 -0
- package/src/doc/annotationBlocks.ts +117 -0
- package/src/doc/customTemplatesFrontmatter.ts +314 -0
- package/src/doc/customThemesFrontmatter.ts +95 -0
- package/src/doc/docToMarkdown.ts +104 -5
- package/src/doc/getLayers.ts +49 -17
- package/src/doc/index.ts +47 -1
- package/src/doc/markdownToDoc.ts +411 -13
- package/src/doc/mediaAnnotations.ts +100 -0
- package/src/doc/resolveDocTheme.ts +55 -0
- package/src/doc/standaloneAnnotation.ts +58 -0
- package/src/doc/structuredData.ts +301 -0
- package/src/doc/templateInputs.ts +240 -0
- package/src/doc/templates/__tests__/customTemplate.test.ts +170 -0
- package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
- package/src/doc/templates/accentImage.ts +30 -6
- package/src/doc/templates/captionUtils.ts +23 -0
- package/src/doc/templates/comparisonBar.ts +33 -18
- package/src/doc/templates/coverBlock.ts +35 -5
- package/src/doc/templates/customTemplate.ts +51 -0
- package/src/doc/templates/dataTable.ts +37 -19
- package/src/doc/templates/dateEvent.ts +41 -20
- package/src/doc/templates/definitionCard.ts +26 -18
- package/src/doc/templates/diagramBlock.ts +204 -0
- package/src/doc/templates/diagramLayout.ts +159 -0
- package/src/doc/templates/drawingBlock.ts +325 -0
- package/src/doc/templates/drawingLayout.ts +539 -0
- package/src/doc/templates/factCard.ts +54 -26
- package/src/doc/templates/fallbackBlock.ts +123 -0
- package/src/doc/templates/featureBlock.ts +20 -13
- package/src/doc/templates/fullBleedQuote.ts +18 -8
- package/src/doc/templates/imageWithCaption.ts +30 -15
- package/src/doc/templates/index.ts +225 -46
- package/src/doc/templates/inputDescriptors.ts +432 -0
- package/src/doc/templates/layoutBlock.ts +43 -0
- package/src/doc/templates/layoutLayout.ts +243 -0
- package/src/doc/templates/listBlock.ts +56 -30
- package/src/doc/templates/mapBlock.ts +15 -12
- package/src/doc/templates/metadata.ts +120 -0
- package/src/doc/templates/persistentLayers.ts +129 -11
- package/src/doc/templates/photoGrid.ts +22 -34
- package/src/doc/templates/pullQuote.ts +30 -12
- package/src/doc/templates/quoteBlock.ts +54 -29
- package/src/doc/templates/sectionHeader.ts +25 -11
- package/src/doc/templates/statHighlight.ts +39 -22
- package/src/doc/templates/titleBlock.ts +59 -21
- package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +487 -0
- package/src/doc/templates/tokens/resolveTokens.ts +453 -0
- package/src/doc/templates/twoColumn.ts +48 -30
- package/src/doc/templates/videoPullQuote.ts +22 -12
- package/src/doc/templates/videoWithCaption.ts +21 -15
- package/src/doc/utils/animationUtils.ts +267 -3
- package/src/doc/utils/applyRenderStyle.ts +113 -0
- package/src/doc/utils/imageTreatment.ts +70 -0
- package/src/doc/utils/nearest.ts +59 -0
- package/src/doc/utils/shapeGeometry.ts +464 -0
- package/src/doc/utils/themeUtils.ts +75 -1
- package/src/doc/validate.ts +575 -0
- package/src/generate/contentExtractor.ts +148 -35
- package/src/generate/index.ts +0 -3
- package/src/icons/index.ts +8 -0
- package/src/icons/inlineIconMarker.ts +73 -0
- package/src/imageEdit/export.ts +85 -2
- package/src/imageEdit/index.ts +1 -0
- package/src/imageEdit/persistence.ts +51 -0
- package/src/index.ts +2 -0
- package/src/jsonForm/index.ts +2 -0
- package/src/jsonForm/tokens.ts +78 -0
- package/src/markdown/annotationCoercion.ts +295 -0
- package/src/markdown/attrTokens.ts +241 -0
- package/src/markdown/convert.ts +229 -26
- package/src/markdown/index.ts +37 -0
- package/src/markdown/sanitize.ts +318 -0
- package/src/markdown/stringify.ts +96 -6
- package/src/markdown/types.ts +66 -0
- package/src/markdown/utils.ts +111 -2
- package/src/recommend/templates.ts +24 -0
- package/src/schemas/BlockTemplates.ts +133 -4
- package/src/schemas/CustomTemplates.ts +126 -0
- package/src/schemas/Doc.ts +387 -65
- package/src/schemas/ImageEditDoc.ts +11 -4
- package/src/schemas/Media.ts +169 -0
- package/src/schemas/Theme.ts +23 -1
- package/src/schemas/Transitions.ts +224 -0
- package/src/schemas/colorUtils.ts +33 -0
- package/src/schemas/fontStacks.ts +5 -2
- package/src/schemas/index.ts +3 -0
- package/src/schemas/themeCompile.ts +24 -5
- package/src/schemas/themeValidator.ts +49 -11
- package/src/schemas/themes/cinematic.json +69 -12
- package/src/schemas/themes/documentary.json +58 -11
- package/src/schemas/themes/gezellig.json +60 -11
- package/src/schemas/themes/magazine.json +54 -9
- package/src/schemas/themes/morning-light.json +52 -9
- package/src/schemas/themes/standard-dark.json +48 -13
- package/src/schemas/themes/standard.json +44 -12
- package/src/schemas/themes/tech-dark.json +65 -12
- package/src/spatial/Geohash.ts +52 -21
- package/src/storage/ContentContainer.ts +6 -2
- package/src/transform/applyTransform.ts +43 -4
- package/src/transform/index.ts +1 -0
- package/src/transform/registry.ts +44 -13
- package/src/transform/styles/dataDriven.ts +2 -1
- package/src/transform/styles/documentary.ts +2 -1
- package/src/transform/styles/magazine.ts +3 -1
- package/src/transform/styles/minimal.ts +2 -1
- package/src/transform/styles/narrative.ts +3 -1
- package/src/transform/templateSelector.ts +87 -11
- package/src/transform/types.ts +35 -2
- package/dist/chunk-2TMAWB4Y.js.map +0 -1
- package/dist/chunk-3E5F2XMR.js.map +0 -1
- package/dist/chunk-3K5OG6KI.js.map +0 -1
- package/dist/chunk-4X3JQXNM.js +0 -35
- package/dist/chunk-4X3JQXNM.js.map +0 -1
- package/dist/chunk-57CVQCFX.js.map +0 -1
- package/dist/chunk-5WFRKURX.js.map +0 -1
- package/dist/chunk-7UDSRZKG.js.map +0 -1
- package/dist/chunk-B4EEON3N.js.map +0 -1
- package/dist/chunk-FR2RBTKO.js.map +0 -1
- package/dist/chunk-H3AXU4MJ.js +0 -179
- package/dist/chunk-H3AXU4MJ.js.map +0 -1
- package/dist/chunk-K32VJONL.js.map +0 -1
- package/dist/chunk-KSWJR66U.js +0 -3706
- package/dist/chunk-KSWJR66U.js.map +0 -1
- package/dist/chunk-MYIH7FWD.js +0 -145
- package/dist/chunk-MYIH7FWD.js.map +0 -1
- package/dist/chunk-ZTX4PFFT.js.map +0 -1
- package/src/__tests__/slideshowGenerator.test.ts +0 -128
- package/src/generate/slideshowGenerator.ts +0 -273
|
@@ -11,24 +11,35 @@
|
|
|
11
11
|
|
|
12
12
|
import type { Layer } from '../../schemas/Doc.js';
|
|
13
13
|
import type { DataTableInput, TemplateContext } from '../../schemas/BlockTemplates.js';
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {
|
|
15
|
+
getThemeFont,
|
|
16
|
+
resolveColorScheme,
|
|
17
|
+
shouldUseShadow,
|
|
18
|
+
themedFontSize,
|
|
19
|
+
themedSurfaceGradient,
|
|
20
|
+
} from '../utils/themeUtils.js';
|
|
21
|
+
import { pickContrastingText, withAlpha } from '../../schemas/colorUtils.js';
|
|
16
22
|
import { createBackgroundLayer } from './captionUtils.js';
|
|
17
23
|
|
|
18
24
|
export function dataTable(input: DataTableInput, context: TemplateContext): Layer[] {
|
|
19
25
|
const { title, headers, rows, align, colorScheme } = input;
|
|
20
|
-
const { theme } = context;
|
|
26
|
+
const { theme, viewport } = context;
|
|
21
27
|
|
|
22
28
|
const colors = resolveColorScheme(context, colorScheme);
|
|
23
|
-
const titleFontSize =
|
|
24
|
-
const tableFontSize =
|
|
29
|
+
const titleFontSize = themedFontSize(48, context, true);
|
|
30
|
+
const tableFontSize = themedFontSize(28, context, false);
|
|
25
31
|
|
|
26
|
-
const layers: Layer[] = [
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const layers: Layer[] = [createBackgroundLayer('bg', themedSurfaceGradient(context, 170))];
|
|
33
|
+
|
|
34
|
+
// Size the table band from its natural content height (header + rows at
|
|
35
|
+
// ~2.4× line height for cell padding) and center the title+table as one
|
|
36
|
+
// group — a full-height band left a ~200px orphan gap under the title.
|
|
37
|
+
// `rows` is required by the schema but may be missing on partially-
|
|
38
|
+
// authored blocks in live preview; treat it as empty rather than throwing.
|
|
39
|
+
const rowCount = (Array.isArray(rows) ? rows.length : 0) + 1;
|
|
40
|
+
const naturalTableHPct = Math.min(74, ((rowCount * tableFontSize * 2.4) / viewport.height) * 100);
|
|
41
|
+
const titleBandPct = title ? (titleFontSize * 2.2 * 100) / viewport.height : 0;
|
|
42
|
+
const groupTopPct = Math.max(8, (100 - titleBandPct - naturalTableHPct) / 2);
|
|
32
43
|
|
|
33
44
|
// Optional title above the table
|
|
34
45
|
if (title) {
|
|
@@ -43,15 +54,22 @@ export function dataTable(input: DataTableInput, context: TemplateContext): Laye
|
|
|
43
54
|
fontWeight: 'bold',
|
|
44
55
|
color: theme.colors.text,
|
|
45
56
|
textAlign: 'center',
|
|
46
|
-
shadow:
|
|
57
|
+
shadow: shouldUseShadow(context),
|
|
47
58
|
},
|
|
48
59
|
},
|
|
49
|
-
position: {
|
|
60
|
+
position: {
|
|
61
|
+
x: '50%',
|
|
62
|
+
y: `${groupTopPct + titleBandPct / 2}%`,
|
|
63
|
+
width: '80%',
|
|
64
|
+
anchor: 'center',
|
|
65
|
+
},
|
|
50
66
|
animation: { type: 'fadeIn', duration: 0.8 },
|
|
51
67
|
});
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
// Table layer
|
|
70
|
+
// Table layer. Header text color is picked for contrast against the
|
|
71
|
+
// header fill — pairing two mid-tones from the same scheme made the
|
|
72
|
+
// header row read as a smudge in most themes.
|
|
55
73
|
layers.push({
|
|
56
74
|
type: 'table',
|
|
57
75
|
id: 'table',
|
|
@@ -61,10 +79,10 @@ export function dataTable(input: DataTableInput, context: TemplateContext): Laye
|
|
|
61
79
|
align,
|
|
62
80
|
style: {
|
|
63
81
|
headerBackground: colors.accent,
|
|
64
|
-
headerColor: colors.
|
|
65
|
-
cellBackground:
|
|
82
|
+
headerColor: pickContrastingText(colors.accent),
|
|
83
|
+
cellBackground: withAlpha(theme.colors.text, 0.04),
|
|
66
84
|
cellColor: theme.colors.text,
|
|
67
|
-
borderColor:
|
|
85
|
+
borderColor: withAlpha(theme.colors.text, 0.15),
|
|
68
86
|
fontSize: tableFontSize,
|
|
69
87
|
fontFamily: getThemeFont(context, 'body'),
|
|
70
88
|
headerFontFamily: getThemeFont(context, 'title'),
|
|
@@ -73,9 +91,9 @@ export function dataTable(input: DataTableInput, context: TemplateContext): Laye
|
|
|
73
91
|
},
|
|
74
92
|
position: {
|
|
75
93
|
x: '10%',
|
|
76
|
-
y:
|
|
94
|
+
y: `${groupTopPct + titleBandPct}%`,
|
|
77
95
|
width: '80%',
|
|
78
|
-
height:
|
|
96
|
+
height: `${naturalTableHPct}%`,
|
|
79
97
|
},
|
|
80
98
|
animation: { type: 'fadeIn', duration: 1, delay: title ? 0.4 : 0 },
|
|
81
99
|
});
|
|
@@ -12,37 +12,52 @@
|
|
|
12
12
|
|
|
13
13
|
import type { Layer } from '../../schemas/Doc.js';
|
|
14
14
|
import type { DateEventInput, TemplateContext } from '../../schemas/BlockTemplates.js';
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
getThemeFont,
|
|
17
|
+
shouldUseShadow,
|
|
18
|
+
themedFontSize,
|
|
19
|
+
themedSurfaceGradient,
|
|
20
|
+
themedImageTreatment,
|
|
21
|
+
} from '../utils/themeUtils.js';
|
|
22
|
+
import { oklchDarken, relativeLuminance } from '../../schemas/colorUtils.js';
|
|
17
23
|
import { createAccentLayers, getAccentLayout, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Mood accents tint the hero date, not the surface — the surface always
|
|
27
|
+
* comes from the theme. (Hard-coded dark mood panels used to turn light
|
|
28
|
+
* and warm themes into a cold navy slide.)
|
|
29
|
+
*/
|
|
30
|
+
const MOOD_ACCENTS = {
|
|
31
|
+
neutral: '#63b3ed',
|
|
32
|
+
somber: '#e53e3e',
|
|
33
|
+
celebratory: '#68d391',
|
|
23
34
|
};
|
|
24
35
|
|
|
25
36
|
export function dateEvent(input: DateEventInput, context: TemplateContext): Layer[] {
|
|
26
37
|
const { date, description, footer, mood = 'neutral', accentImage } = input;
|
|
27
38
|
const { theme } = context;
|
|
28
|
-
|
|
39
|
+
// Deepen the mood accent on light surfaces so the hero date keeps contrast.
|
|
40
|
+
const moodAccent = MOOD_ACCENTS[mood];
|
|
41
|
+
const dateColor =
|
|
42
|
+
relativeLuminance(theme.colors.background) > 0.5 ? oklchDarken(moodAccent, 0.25) : moodAccent;
|
|
29
43
|
|
|
30
44
|
// Get layout adjustments if accent image is present
|
|
31
45
|
const accentLayout = accentImage ? getAccentLayout(accentImage.position) : DEFAULT_LAYOUT;
|
|
32
46
|
|
|
33
47
|
// Scale font sizes — date is the hero element
|
|
34
|
-
const dateFontSize =
|
|
35
|
-
const descFontSize =
|
|
36
|
-
const footerFontSize =
|
|
48
|
+
const dateFontSize = themedFontSize(96, context, true);
|
|
49
|
+
const descFontSize = themedFontSize(30, context, false);
|
|
50
|
+
const footerFontSize = themedFontSize(26, context, false);
|
|
37
51
|
|
|
38
52
|
const layers: Layer[] = [
|
|
39
|
-
// Background —
|
|
53
|
+
// Background — theme surface gradient (diagonal to differentiate
|
|
54
|
+
// from other text templates)
|
|
40
55
|
{
|
|
41
56
|
type: 'shape',
|
|
42
57
|
id: 'bg',
|
|
43
58
|
content: {
|
|
44
59
|
shape: 'rect',
|
|
45
|
-
fill:
|
|
60
|
+
fill: themedSurfaceGradient(context, 135),
|
|
46
61
|
},
|
|
47
62
|
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
48
63
|
},
|
|
@@ -50,7 +65,13 @@ export function dateEvent(input: DateEventInput, context: TemplateContext): Laye
|
|
|
50
65
|
|
|
51
66
|
// Add accent image layers (behind text, after background)
|
|
52
67
|
if (accentImage) {
|
|
53
|
-
layers.push(
|
|
68
|
+
layers.push(
|
|
69
|
+
...createAccentLayers(
|
|
70
|
+
accentImage,
|
|
71
|
+
input.id,
|
|
72
|
+
themedImageTreatment(context, input.imageTreatment),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
// Date — hero element, much larger
|
|
@@ -63,8 +84,8 @@ export function dateEvent(input: DateEventInput, context: TemplateContext): Laye
|
|
|
63
84
|
fontSize: dateFontSize,
|
|
64
85
|
fontFamily: getThemeFont(context, 'title'),
|
|
65
86
|
fontWeight: 'bold',
|
|
66
|
-
color:
|
|
67
|
-
shadow:
|
|
87
|
+
color: dateColor,
|
|
88
|
+
shadow: shouldUseShadow(context),
|
|
68
89
|
},
|
|
69
90
|
},
|
|
70
91
|
position: {
|
|
@@ -84,10 +105,10 @@ export function dateEvent(input: DateEventInput, context: TemplateContext): Laye
|
|
|
84
105
|
style: {
|
|
85
106
|
fontSize: descFontSize,
|
|
86
107
|
fontFamily: getThemeFont(context, 'body'),
|
|
87
|
-
color: colors.text,
|
|
108
|
+
color: theme.colors.text,
|
|
88
109
|
textAlign: 'center',
|
|
89
|
-
lineHeight: 1.
|
|
90
|
-
shadow:
|
|
110
|
+
lineHeight: 1.6,
|
|
111
|
+
shadow: shouldUseShadow(context),
|
|
91
112
|
},
|
|
92
113
|
},
|
|
93
114
|
position: {
|
|
@@ -110,12 +131,12 @@ export function dateEvent(input: DateEventInput, context: TemplateContext): Laye
|
|
|
110
131
|
fontSize: footerFontSize,
|
|
111
132
|
fontFamily: getThemeFont(context, 'body'),
|
|
112
133
|
color: theme.colors.textMuted,
|
|
113
|
-
shadow:
|
|
134
|
+
shadow: shouldUseShadow(context),
|
|
114
135
|
},
|
|
115
136
|
},
|
|
116
137
|
position: {
|
|
117
138
|
x: accentLayout.textCenterX,
|
|
118
|
-
y: adjustY('
|
|
139
|
+
y: adjustY('72%', accentLayout),
|
|
119
140
|
anchor: 'center',
|
|
120
141
|
},
|
|
121
142
|
animation: { type: 'fadeIn', duration: 1, delay: 3 },
|
|
@@ -10,8 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
import type { Layer } from '../../schemas/Doc.js';
|
|
12
12
|
import type { DefinitionCardInput, TemplateContext } from '../../schemas/BlockTemplates.js';
|
|
13
|
-
import {
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
resolveColorScheme,
|
|
15
|
+
getThemeFont,
|
|
16
|
+
shouldUseShadow,
|
|
17
|
+
themedEntrance,
|
|
18
|
+
themedFontSize,
|
|
19
|
+
themedSurfaceGradient,
|
|
20
|
+
themedImageTreatment,
|
|
21
|
+
} from '../utils/themeUtils.js';
|
|
15
22
|
import { createAccentLayers, getAccentLayout, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
|
|
16
23
|
import { createBackgroundLayer } from './captionUtils.js';
|
|
17
24
|
|
|
@@ -23,20 +30,21 @@ export function definitionCard(input: DefinitionCardInput, context: TemplateCont
|
|
|
23
30
|
// Get layout adjustments if accent image is present
|
|
24
31
|
const accentLayout = accentImage ? getAccentLayout(accentImage.position) : DEFAULT_LAYOUT;
|
|
25
32
|
|
|
26
|
-
const termFontSize =
|
|
27
|
-
const defFontSize =
|
|
28
|
-
const originFontSize =
|
|
33
|
+
const termFontSize = themedFontSize(72, context, true);
|
|
34
|
+
const defFontSize = themedFontSize(32, context, false);
|
|
35
|
+
const originFontSize = themedFontSize(22, context, false);
|
|
29
36
|
|
|
30
|
-
const layers: Layer[] = [
|
|
31
|
-
createBackgroundLayer(
|
|
32
|
-
'bg',
|
|
33
|
-
`linear-gradient(145deg, #1e2030 0%, ${theme.colors.background} 100%)`,
|
|
34
|
-
),
|
|
35
|
-
];
|
|
37
|
+
const layers: Layer[] = [createBackgroundLayer('bg', themedSurfaceGradient(context, 145))];
|
|
36
38
|
|
|
37
39
|
// Add accent image layers
|
|
38
40
|
if (accentImage) {
|
|
39
|
-
layers.push(
|
|
41
|
+
layers.push(
|
|
42
|
+
...createAccentLayers(
|
|
43
|
+
accentImage,
|
|
44
|
+
input.id,
|
|
45
|
+
themedImageTreatment(context, input.imageTreatment),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
// Term — large, accent-colored
|
|
@@ -51,7 +59,7 @@ export function definitionCard(input: DefinitionCardInput, context: TemplateCont
|
|
|
51
59
|
fontWeight: 'bold',
|
|
52
60
|
color: colors.text,
|
|
53
61
|
textAlign: 'center',
|
|
54
|
-
shadow:
|
|
62
|
+
shadow: shouldUseShadow(context),
|
|
55
63
|
},
|
|
56
64
|
},
|
|
57
65
|
position: {
|
|
@@ -59,7 +67,7 @@ export function definitionCard(input: DefinitionCardInput, context: TemplateCont
|
|
|
59
67
|
y: adjustY('30%', accentLayout),
|
|
60
68
|
anchor: 'center',
|
|
61
69
|
},
|
|
62
|
-
animation: { type: 'fadeIn', duration: 1.5 },
|
|
70
|
+
animation: themedEntrance(context, 'text', { type: 'fadeIn', duration: 1.5 }),
|
|
63
71
|
});
|
|
64
72
|
|
|
65
73
|
// Horizontal separator line
|
|
@@ -92,7 +100,7 @@ export function definitionCard(input: DefinitionCardInput, context: TemplateCont
|
|
|
92
100
|
textAlign: 'center',
|
|
93
101
|
lineHeight: 1.6,
|
|
94
102
|
maxLines: 4,
|
|
95
|
-
shadow:
|
|
103
|
+
shadow: shouldUseShadow(context),
|
|
96
104
|
},
|
|
97
105
|
},
|
|
98
106
|
position: {
|
|
@@ -101,7 +109,7 @@ export function definitionCard(input: DefinitionCardInput, context: TemplateCont
|
|
|
101
109
|
width: accentLayout.textWidth,
|
|
102
110
|
anchor: 'center',
|
|
103
111
|
},
|
|
104
|
-
animation: { type: 'fadeIn', duration: 1, delay: 0.8 },
|
|
112
|
+
animation: themedEntrance(context, 'text', { type: 'fadeIn', duration: 1, delay: 0.8 }),
|
|
105
113
|
});
|
|
106
114
|
|
|
107
115
|
// Origin if provided
|
|
@@ -116,12 +124,12 @@ export function definitionCard(input: DefinitionCardInput, context: TemplateCont
|
|
|
116
124
|
fontFamily: getThemeFont(context, 'body'),
|
|
117
125
|
color: theme.colors.textMuted,
|
|
118
126
|
textAlign: 'center',
|
|
119
|
-
shadow:
|
|
127
|
+
shadow: shouldUseShadow(context),
|
|
120
128
|
},
|
|
121
129
|
},
|
|
122
130
|
position: {
|
|
123
131
|
x: accentLayout.textCenterX,
|
|
124
|
-
y: adjustY('
|
|
132
|
+
y: adjustY('70%', accentLayout),
|
|
125
133
|
anchor: 'center',
|
|
126
134
|
},
|
|
127
135
|
animation: { type: 'fadeIn', duration: 0.8, delay: 1.5 },
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagram block template.
|
|
3
|
+
*
|
|
4
|
+
* Renders the parent block's children as a node-and-edge diagram:
|
|
5
|
+
* - Each child becomes a rounded-rect node card with its title as label.
|
|
6
|
+
* - Each `child.connectsTo` entry becomes a path edge (cubic-bezier curve
|
|
7
|
+
* with an arrowhead at the target end).
|
|
8
|
+
* - Children with `x`/`y` set are placed at those coordinates; the rest
|
|
9
|
+
* are auto-laid out below in a square-ish grid (see `diagramLayout.ts`).
|
|
10
|
+
*
|
|
11
|
+
* Layout coordinates from `x=`/`y=` are author-defined units. The
|
|
12
|
+
* template computes a bounding box of all nodes and scales it to fit
|
|
13
|
+
* the block's viewport with padding, so users don't have to think about
|
|
14
|
+
* absolute pixel ranges.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Layer, ShapeLayer, TextLayer, PathLayer, MarkerStyle } from '../../schemas/Doc.js';
|
|
18
|
+
import type { DiagramBlockInput, TemplateContext } from '../../schemas/BlockTemplates.js';
|
|
19
|
+
import { resolveColorScheme, getThemeFont, themedFontSize } from '../utils/themeUtils.js';
|
|
20
|
+
import { clipEndpoints, connectorPath, lineStyleDasharray } from '../utils/shapeGeometry.js';
|
|
21
|
+
import { computeDiagramLayout, type DiagramNodePosition } from './diagramLayout.js';
|
|
22
|
+
|
|
23
|
+
const NODE_WIDTH = 180;
|
|
24
|
+
const NODE_HEIGHT = 64;
|
|
25
|
+
const PADDING = 80;
|
|
26
|
+
|
|
27
|
+
export function diagramBlock(input: DiagramBlockInput, context: TemplateContext): Layer[] {
|
|
28
|
+
const { theme, viewport, children = [] } = context;
|
|
29
|
+
const colors = resolveColorScheme(context, input.colorScheme ?? 'blue');
|
|
30
|
+
|
|
31
|
+
const layout = computeDiagramLayout(children);
|
|
32
|
+
if (layout.nodes.length === 0) {
|
|
33
|
+
// Empty diagram — render a single hint label so the block has visible content.
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
type: 'text',
|
|
37
|
+
id: 'diagram-empty',
|
|
38
|
+
content: {
|
|
39
|
+
text: input.title ?? 'Empty diagram',
|
|
40
|
+
style: {
|
|
41
|
+
fontSize: themedFontSize(36, context, true),
|
|
42
|
+
fontFamily: getThemeFont(context, 'title'),
|
|
43
|
+
color: theme.colors.textMuted,
|
|
44
|
+
textAlign: 'center',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
position: { x: '50%', y: '50%', anchor: 'center' },
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Compute the bounding box of all nodes in author coordinates.
|
|
53
|
+
const minX = Math.min(...layout.nodes.map((n) => n.x));
|
|
54
|
+
const maxX = Math.max(...layout.nodes.map((n) => n.x + NODE_WIDTH));
|
|
55
|
+
const minY = Math.min(...layout.nodes.map((n) => n.y));
|
|
56
|
+
const maxY = Math.max(...layout.nodes.map((n) => n.y + NODE_HEIGHT));
|
|
57
|
+
|
|
58
|
+
// Available area in viewport pixels (after reserving space for the title, if any).
|
|
59
|
+
const titleHeight = input.title ? 120 : 0;
|
|
60
|
+
const availW = Math.max(1, viewport.width - PADDING * 2);
|
|
61
|
+
const availH = Math.max(1, viewport.height - PADDING * 2 - titleHeight);
|
|
62
|
+
const contentW = Math.max(1, maxX - minX);
|
|
63
|
+
const contentH = Math.max(1, maxY - minY);
|
|
64
|
+
|
|
65
|
+
// Uniform scale so the diagram fits both dimensions. Small diagrams are
|
|
66
|
+
// allowed to grow (everything here is vector) up to 1.8× so a three-node
|
|
67
|
+
// flow doesn't render as a tiny cluster in an empty canvas; label fonts
|
|
68
|
+
// and strokes scale with the nodes, clamped so text never balloons.
|
|
69
|
+
const scale = Math.min(availW / contentW, availH / contentH, 1.8);
|
|
70
|
+
const fontAdj = Math.min(Math.max(scale, 1), 1.5);
|
|
71
|
+
const strokeW = Math.round(2 * fontAdj);
|
|
72
|
+
|
|
73
|
+
// Compute viewport-pixel coordinates of each node's top-left corner.
|
|
74
|
+
// The title and the scaled diagram are centered together as one group —
|
|
75
|
+
// a top-pinned title over a center-floated diagram split the block into
|
|
76
|
+
// two stranded pieces.
|
|
77
|
+
const scaledW = contentW * scale;
|
|
78
|
+
const scaledH = contentH * scale;
|
|
79
|
+
const groupTop = Math.max(PADDING / 2, (viewport.height - titleHeight - scaledH) / 2);
|
|
80
|
+
const offsetX = PADDING + (availW - scaledW) / 2;
|
|
81
|
+
const offsetY = groupTop + titleHeight;
|
|
82
|
+
const transform = (n: DiagramNodePosition): { x: number; y: number; w: number; h: number } => ({
|
|
83
|
+
x: offsetX + (n.x - minX) * scale,
|
|
84
|
+
y: offsetY + (n.y - minY) * scale,
|
|
85
|
+
w: NODE_WIDTH * scale,
|
|
86
|
+
h: NODE_HEIGHT * scale,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const layers: Layer[] = [];
|
|
90
|
+
|
|
91
|
+
// Optional title above the diagram.
|
|
92
|
+
if (input.title) {
|
|
93
|
+
layers.push({
|
|
94
|
+
type: 'text',
|
|
95
|
+
id: 'diagram-title',
|
|
96
|
+
content: {
|
|
97
|
+
text: input.title,
|
|
98
|
+
style: {
|
|
99
|
+
fontSize: themedFontSize(40, context, true),
|
|
100
|
+
fontFamily: getThemeFont(context, 'title'),
|
|
101
|
+
fontWeight: 'bold',
|
|
102
|
+
color: theme.colors.text,
|
|
103
|
+
textAlign: 'center',
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
position: { x: '50%', y: groupTop + titleHeight / 2 - 16, anchor: 'center' },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Position lookup by id for edge routing.
|
|
111
|
+
const positions = new Map<string, { cx: number; cy: number; rx: number; ry: number }>();
|
|
112
|
+
for (const node of layout.nodes) {
|
|
113
|
+
const t = transform(node);
|
|
114
|
+
positions.set(node.id, { cx: t.x + t.w / 2, cy: t.y + t.h / 2, rx: t.w / 2, ry: t.h / 2 });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Edge styling (applies to all edges; per-edge styling lives on the nodes).
|
|
118
|
+
const startMarker: MarkerStyle = input.startStyle ?? 'none';
|
|
119
|
+
const endMarker: MarkerStyle = input.endStyle ?? 'arrow';
|
|
120
|
+
const edgeDash = lineStyleDasharray(input.lineStyle);
|
|
121
|
+
|
|
122
|
+
// Edges first so they sit behind nodes.
|
|
123
|
+
for (const edge of layout.edges) {
|
|
124
|
+
const a = positions.get(edge.source);
|
|
125
|
+
const b = positions.get(edge.target);
|
|
126
|
+
if (!a || !b) continue;
|
|
127
|
+
const { start, end } = clipEndpoints(a, b);
|
|
128
|
+
const pathLayer: PathLayer = {
|
|
129
|
+
type: 'path',
|
|
130
|
+
id: `edge-${edge.id}`,
|
|
131
|
+
content: {
|
|
132
|
+
d: connectorPath(input.edgeStyle ?? 'curved', start, end),
|
|
133
|
+
stroke: colors.text ?? theme.colors.primary,
|
|
134
|
+
strokeWidth: strokeW,
|
|
135
|
+
fill: 'none',
|
|
136
|
+
...(edgeDash ? { dasharray: edgeDash } : {}),
|
|
137
|
+
...(startMarker !== 'none' ? { startMarker } : {}),
|
|
138
|
+
...(endMarker !== 'none' ? { endMarker } : {}),
|
|
139
|
+
},
|
|
140
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
141
|
+
};
|
|
142
|
+
layers.push(pathLayer);
|
|
143
|
+
|
|
144
|
+
if (edge.type) {
|
|
145
|
+
// Connection-type label, floated just above the edge midpoint so the
|
|
146
|
+
// line never strikes through the text.
|
|
147
|
+
const mx = (a.cx + b.cx) / 2;
|
|
148
|
+
const my = (a.cy + b.cy) / 2;
|
|
149
|
+
const labelFontSize = themedFontSize(Math.round(18 * fontAdj), context, false);
|
|
150
|
+
const labelLayer: TextLayer = {
|
|
151
|
+
type: 'text',
|
|
152
|
+
id: `edge-label-${edge.id}`,
|
|
153
|
+
content: {
|
|
154
|
+
text: edge.type,
|
|
155
|
+
style: {
|
|
156
|
+
fontSize: labelFontSize,
|
|
157
|
+
fontFamily: getThemeFont(context, 'body'),
|
|
158
|
+
color: theme.colors.textMuted,
|
|
159
|
+
textAlign: 'center',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
position: { x: mx, y: my - labelFontSize * 0.9, anchor: 'center' },
|
|
163
|
+
};
|
|
164
|
+
layers.push(labelLayer);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Nodes (card + label) on top of edges.
|
|
169
|
+
for (const node of layout.nodes) {
|
|
170
|
+
const t = transform(node);
|
|
171
|
+
const card: ShapeLayer = {
|
|
172
|
+
type: 'shape',
|
|
173
|
+
id: `node-card-${node.id}`,
|
|
174
|
+
content: {
|
|
175
|
+
shape: 'rect',
|
|
176
|
+
fill: colors.bg ?? theme.colors.backgroundLight,
|
|
177
|
+
stroke: colors.text ?? theme.colors.primary,
|
|
178
|
+
strokeWidth: strokeW,
|
|
179
|
+
borderRadius: input.nodeShape === 'pill' ? t.h / 2 : 10,
|
|
180
|
+
},
|
|
181
|
+
position: { x: t.x, y: t.y, width: t.w, height: t.h },
|
|
182
|
+
};
|
|
183
|
+
layers.push(card);
|
|
184
|
+
|
|
185
|
+
const label: TextLayer = {
|
|
186
|
+
type: 'text',
|
|
187
|
+
id: `node-label-${node.id}`,
|
|
188
|
+
content: {
|
|
189
|
+
text: node.label,
|
|
190
|
+
style: {
|
|
191
|
+
fontSize: themedFontSize(Math.round(22 * fontAdj), context, false),
|
|
192
|
+
fontFamily: getThemeFont(context, 'body'),
|
|
193
|
+
fontWeight: 'bold',
|
|
194
|
+
color: colors.text ?? theme.colors.text,
|
|
195
|
+
textAlign: 'center',
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
position: { x: t.x + t.w / 2, y: t.y + t.h / 2, anchor: 'center', width: t.w },
|
|
199
|
+
};
|
|
200
|
+
layers.push(label);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return layers;
|
|
204
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagram layout — shared between the read-only SVG render path
|
|
3
|
+
* (`diagramBlock` template) and the interactive editor (React Flow in
|
|
4
|
+
* `@bendyline/squisq-editor-react`). Same children + same options always
|
|
5
|
+
* produce the same node positions and edge list, so previews and the
|
|
6
|
+
* editor stay visually consistent.
|
|
7
|
+
*
|
|
8
|
+
* Layout rules:
|
|
9
|
+
* 1. Children with both `x` and `y` set keep their authored coordinates.
|
|
10
|
+
* 2. Children missing either coordinate are auto-placed in a square-ish
|
|
11
|
+
* grid in declaration order, offset below the bounding box of the
|
|
12
|
+
* explicitly-positioned children.
|
|
13
|
+
* 3. Edges come from each child's `connectsTo`. Targets that don't match
|
|
14
|
+
* any sibling id are dropped (and counted in `warnings`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Block, BlockConnection } from '../../schemas/Doc.js';
|
|
18
|
+
|
|
19
|
+
export interface DiagramNodePosition {
|
|
20
|
+
/** Block id (matches `Block.id`). */
|
|
21
|
+
id: string;
|
|
22
|
+
/** Display label (block title or fallback to id). */
|
|
23
|
+
label: string;
|
|
24
|
+
/** Pixel x coordinate (canvas-relative). */
|
|
25
|
+
x: number;
|
|
26
|
+
/** Pixel y coordinate (canvas-relative). */
|
|
27
|
+
y: number;
|
|
28
|
+
/** True when the position came from `x=`/`y=` attributes; false when auto-laid out. */
|
|
29
|
+
pinned: boolean;
|
|
30
|
+
/** Optional `.class` tokens carried over from the heading. */
|
|
31
|
+
classes?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DiagramEdge {
|
|
35
|
+
/** Synthetic stable edge id. */
|
|
36
|
+
id: string;
|
|
37
|
+
/** Source node id. */
|
|
38
|
+
source: string;
|
|
39
|
+
/** Target node id. */
|
|
40
|
+
target: string;
|
|
41
|
+
/** Connection type from `connectsTo=target:type` (used as edge label). */
|
|
42
|
+
type?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DiagramLayout {
|
|
46
|
+
nodes: DiagramNodePosition[];
|
|
47
|
+
edges: DiagramEdge[];
|
|
48
|
+
warnings: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DiagramLayoutOptions {
|
|
52
|
+
/** Horizontal grid pitch between auto-laid-out nodes. Default: 260 (node width 180 + 80 air). */
|
|
53
|
+
gapX?: number;
|
|
54
|
+
/** Vertical grid pitch between auto-laid-out nodes. Default: 140. */
|
|
55
|
+
gapY?: number;
|
|
56
|
+
/** Padding around the explicit-positions bounding box before the grid starts. Default: 80. */
|
|
57
|
+
gridPad?: number;
|
|
58
|
+
/** Origin x for the grid when no explicit nodes anchor it. Default: 80. */
|
|
59
|
+
gridOriginX?: number;
|
|
60
|
+
/** Origin y for the grid when no explicit nodes anchor it. Default: 80. */
|
|
61
|
+
gridOriginY?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEFAULT_OPTS: Required<DiagramLayoutOptions> = {
|
|
65
|
+
gapX: 260,
|
|
66
|
+
gapY: 140,
|
|
67
|
+
gridPad: 80,
|
|
68
|
+
gridOriginX: 80,
|
|
69
|
+
gridOriginY: 80,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compute node positions and edges for a diagram from a parent block's
|
|
74
|
+
* children. Pure function — same inputs always produce the same output.
|
|
75
|
+
*/
|
|
76
|
+
export function computeDiagramLayout(
|
|
77
|
+
children: readonly Block[],
|
|
78
|
+
options: DiagramLayoutOptions = {},
|
|
79
|
+
): DiagramLayout {
|
|
80
|
+
const opts = { ...DEFAULT_OPTS, ...options };
|
|
81
|
+
const warnings: string[] = [];
|
|
82
|
+
|
|
83
|
+
// First pass: extract explicit positions, record which children still need a grid slot.
|
|
84
|
+
type Entry = { child: Block; pinned: boolean; x: number; y: number };
|
|
85
|
+
const entries: Entry[] = [];
|
|
86
|
+
const unpositionedIdx: number[] = [];
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < children.length; i++) {
|
|
89
|
+
const c = children[i];
|
|
90
|
+
const hasX = typeof c.x === 'number';
|
|
91
|
+
const hasY = typeof c.y === 'number';
|
|
92
|
+
if (hasX && hasY) {
|
|
93
|
+
entries.push({ child: c, pinned: true, x: c.x as number, y: c.y as number });
|
|
94
|
+
} else {
|
|
95
|
+
entries.push({ child: c, pinned: false, x: 0, y: 0 });
|
|
96
|
+
unpositionedIdx.push(i);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Second pass: lay out unpositioned children in a square-ish grid.
|
|
101
|
+
if (unpositionedIdx.length > 0) {
|
|
102
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt(unpositionedIdx.length)));
|
|
103
|
+
// Find the bottom-right of the pinned bounding box so the grid sits below it.
|
|
104
|
+
let baseX = opts.gridOriginX;
|
|
105
|
+
let baseY = opts.gridOriginY;
|
|
106
|
+
const pinned = entries.filter((e) => e.pinned);
|
|
107
|
+
if (pinned.length > 0) {
|
|
108
|
+
const minX = Math.min(...pinned.map((e) => e.x));
|
|
109
|
+
const maxY = Math.max(...pinned.map((e) => e.y));
|
|
110
|
+
baseX = minX;
|
|
111
|
+
baseY = maxY + opts.gridPad + opts.gapY;
|
|
112
|
+
}
|
|
113
|
+
unpositionedIdx.forEach((entryIdx, k) => {
|
|
114
|
+
const col = k % cols;
|
|
115
|
+
const row = Math.floor(k / cols);
|
|
116
|
+
entries[entryIdx].x = baseX + col * opts.gapX;
|
|
117
|
+
entries[entryIdx].y = baseY + row * opts.gapY;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Third pass: emit nodes + the id-set used to validate edge targets.
|
|
122
|
+
const nodes: DiagramNodePosition[] = entries.map((e) => ({
|
|
123
|
+
id: e.child.id,
|
|
124
|
+
label: e.child.title ?? e.child.id,
|
|
125
|
+
x: e.x,
|
|
126
|
+
y: e.y,
|
|
127
|
+
pinned: e.pinned,
|
|
128
|
+
...(e.child.classes && e.child.classes.length > 0 ? { classes: e.child.classes } : {}),
|
|
129
|
+
}));
|
|
130
|
+
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
131
|
+
|
|
132
|
+
// Fourth pass: emit edges from each child's connectsTo, dropping ghosts.
|
|
133
|
+
const edges: DiagramEdge[] = [];
|
|
134
|
+
const seenEdgeIds = new Set<string>();
|
|
135
|
+
for (const child of children) {
|
|
136
|
+
if (!child.connectsTo) continue;
|
|
137
|
+
for (const conn of child.connectsTo) {
|
|
138
|
+
if (!nodeIds.has(conn.target)) {
|
|
139
|
+
warnings.push(`Edge from "${child.id}" to "${conn.target}" dropped: no such sibling node`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const edgeId = makeEdgeId(child.id, conn);
|
|
143
|
+
if (seenEdgeIds.has(edgeId)) continue;
|
|
144
|
+
seenEdgeIds.add(edgeId);
|
|
145
|
+
edges.push({
|
|
146
|
+
id: edgeId,
|
|
147
|
+
source: child.id,
|
|
148
|
+
target: conn.target,
|
|
149
|
+
...(conn.type ? { type: conn.type } : {}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { nodes, edges, warnings };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function makeEdgeId(source: string, conn: BlockConnection): string {
|
|
158
|
+
return conn.type ? `${source}->${conn.target}:${conn.type}` : `${source}->${conn.target}`;
|
|
159
|
+
}
|