@codename/deck 0.2.0 → 0.4.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/src/kit.js CHANGED
@@ -3,18 +3,24 @@
3
3
  // deck's own kit holds the usual layouts; a host's (an organisation's, a project's) and the deck
4
4
  // file's own `kit:` go over it, in that order. A layout is a grid: its slots by name, the areas
5
5
  // they take, its tracks, how its words sit, what paper it is on, how big its logo shows, and the
6
- // PowerPoint layout it is nearest to; a kit may turn one of the usual layouts off. Its tokens may
7
- // hold a logo, a picture inside the kit and never an address. Everything is checked against a small
8
- // grammar; what fails is dropped, with a reason. Pure.
6
+ // PowerPoint layout it is nearest to, whether its lists are drawn as a mind map or a figure (cards, numbers, a timeline,
7
+ // chevrons, a programme, a pyramid, a bento), whether its `##` parts face each other or make a matrix, whether it shows
8
+ // the deck's contents, and the art a template draws behind it, layers among the kit's pictures; a kit may turn one of
9
+ // the usual layouts off. Its tokens may hold a logo, a picture inside
10
+ // the kit and never an address, as its pictures are. Everything is checked against a small grammar;
11
+ // what fails is dropped, with a reason. Pure.
9
12
 
10
13
  /** @typedef {{ required?: boolean, max_words?: number, max_chars?: number, items?: [number, number], item_max_words?: number, heading_max_words?: number, kind?: string }} Budget */
11
- /** @typedef {{ label?: Record<string, string> | string, intent?: string, use_when?: string, avoid_when?: string, slots: string[], budgets: Record<string, Budget>, areas: string[][], columns: string[], rows: string[], align: 'start' | 'center' | 'end', justify: 'start' | 'center' | 'end', paper: 'paper' | 'fill' | 'ink' | 'soft', title: 'normal' | 'large' | 'huge', logo: 'big' | 'small' | 'none', pptx: string, numbered: boolean }} Layout */
12
- /** @typedef {{ name?: string, tokens: Record<string, any>, layouts: Record<string, Layout> }} Kit */
14
+ /** @typedef {'bullets' | 'mindmap' | 'steps' | 'metrics' | 'timeline' | 'process' | 'agenda' | 'pyramid' | 'bento'} List */
15
+ /** @typedef {{ label?: Record<string, string> | string, intent?: string, use_when?: string, avoid_when?: string, slots: string[], budgets: Record<string, Budget>, areas: string[][], columns: string[], rows: string[], align: 'start' | 'center' | 'end', justify: 'start' | 'center' | 'end', paper: 'paper' | 'fill' | 'ink' | 'soft', title: 'normal' | 'large' | 'huge', logo: 'big' | 'small' | 'none', lists: List, parts: 'none' | 'versus' | 'matrix', contents: boolean, pptx: string, numbered: boolean, art?: string[] }} Layout */
16
+ /** @typedef {{ name?: string, tokens: Record<string, any>, layouts: Record<string, Layout>, pictures: Record<string, string> }} Kit */
13
17
 
14
18
  const NAME = /^[a-z][a-z0-9-]{0,31}$/
15
19
  const TRACK = /^(auto|min-content|max-content|\d{1,2}(\.\d)?fr|\d{1,3}%)$/
16
20
  const PPTX = ['Title Slide', 'Title and Content', 'Section Header', 'Two Content', 'Comparison', 'Title Only', 'Blank', 'Content with Caption', 'Picture with Caption']
17
- const ENUMS = /** @type {const} */ ({ align: ['start', 'center', 'end'], justify: ['start', 'center', 'end'], paper: ['paper', 'fill', 'ink', 'soft'], title: ['normal', 'large', 'huge'], logo: ['small', 'big', 'none'] })
21
+ const ENUMS = /** @type {const} */ ({
22
+ align: ['start', 'center', 'end'], justify: ['start', 'center', 'end'], paper: ['paper', 'fill', 'ink', 'soft'], title: ['normal', 'large', 'huge'], logo: ['small', 'big', 'none'],
23
+ lists: ['bullets', 'mindmap', 'steps', 'metrics', 'timeline', 'process', 'agenda', 'pyramid', 'bento'], parts: ['none', 'versus', 'matrix'] })
18
24
  /** The corners a logo sits in, as `logoAt` names them, the first by default. */
19
25
  export const CORNERS = ['top-end', 'top-start', 'bottom-end', 'bottom-start']
20
26
 
@@ -24,21 +30,33 @@ export const CORNERS = ['top-end', 'top-start', 'bottom-end', 'bottom-start']
24
30
  */
25
31
  export const logoOf = (value) => (typeof value === 'string' && value.length <= 700_000 && /^data:image\/(png|jpeg|gif|webp|svg\+xml);base64,[a-z0-9+/]+=*$/i.test(value) ? value : null)
26
32
 
33
+ /**
34
+ * A picture of a kit's, a layer of a layout's art drawn behind its words over the whole slide: a picture inside the kit as a
35
+ * logo is, or an SVG written out (`data:image/svg+xml,…`), of 3 MB at most, never an address to fetch; else null. @param {unknown} value
36
+ */
37
+ export const artOf = (value) => (typeof value === 'string' && value.length <= 3_145_728 && /^data:image\/(?:(?:png|jpeg|gif|webp|svg\+xml);base64,[a-z0-9+/]+=*|svg\+xml,[\w.!~*'()%/+=-]*)$/i.test(value) ? value : null)
38
+
27
39
  /** The names brains give the usual layouts, for the ones deck has. */
28
40
  export const ALIASES = /** @type {Record<string, string>} */ ({
29
41
  title: 'cover', 'title-slide': 'cover', 'two-columns': 'two-cols', 'two-column': 'two-cols', columns: 'two-cols', comparison: 'two-cols-header',
30
- 'three-columns': 'three-cols', 'section-header': 'section', divider: 'section', big: 'fact', number: 'fact', 'image-text': 'image-right', closing: 'end', thanks: 'end',
42
+ 'three-columns': 'three-cols', 'section-header': 'section', divider: 'section', big: 'fact', number: 'fact', 'image-text': 'image-right', closing: 'end', thanks: 'end', 'mind-map': 'mindmap', map: 'mindmap',
43
+ toc: 'summary', contents: 'summary', 'table-of-contents': 'summary', outline: 'summary', 'sub-section': 'subsection', 'subsection-header': 'subsection',
44
+ cards: 'steps', kpi: 'metrics', kpis: 'metrics', stats: 'metrics', numbers: 'metrics', roadmap: 'timeline', milestones: 'timeline', chevrons: 'process',
45
+ schedule: 'agenda', programme: 'agenda', program: 'agenda', hierarchy: 'pyramid', mosaic: 'bento', vs: 'versus', 'face-off': 'versus', quadrant: 'matrix', quadrants: 'matrix', '2x2': 'matrix',
31
46
  })
32
47
 
33
48
  /** deck's own kit: the usual layouts, the ones Slidev names and the ones PowerPoint has, as data. */
34
49
  export const STANDARD = /** @type {Kit} */ ({
35
50
  name: 'standard',
36
51
  tokens: {},
52
+ pictures: {},
37
53
  layouts: Object.fromEntries(Object.entries(/** @type {Record<string, any>} */ ({
38
54
  default: { label: { en: 'Title and content', fr: 'Titre et contenu' }, pptx: 'Title and Content' },
39
55
  cover: { label: { en: 'Cover', fr: 'Couverture' }, justify: 'center', paper: 'fill', title: 'huge', logo: 'big', pptx: 'Title Slide', numbered: false },
40
56
  intro: { label: { en: 'Introduction', fr: 'Introduction' }, justify: 'center', paper: 'fill', title: 'large', logo: 'big', pptx: 'Title Slide', numbered: false },
41
57
  section: { label: { en: 'Section', fr: 'Section' }, justify: 'center', paper: 'ink', title: 'huge', pptx: 'Section Header', numbered: false },
58
+ subsection: { label: { en: 'Subsection', fr: 'Sous-section' }, justify: 'center', paper: 'soft', title: 'large', pptx: 'Section Header', numbered: false },
59
+ summary: { label: { en: 'Contents', fr: 'Sommaire' }, contents: true, pptx: 'Title and Content' },
42
60
  center: { label: { en: 'Centred', fr: 'Centré' }, justify: 'center', align: 'center', pptx: 'Title Only' },
43
61
  statement: { label: { en: 'Statement', fr: 'Affirmation' }, justify: 'center', align: 'center', title: 'large', pptx: 'Title Only' },
44
62
  fact: { label: { en: 'Big number', fr: 'Grand chiffre' }, justify: 'center', align: 'center', title: 'huge', pptx: 'Title Only' },
@@ -52,6 +70,16 @@ export const STANDARD = /** @type {Kit} */ ({
52
70
  'image-left': { label: { en: 'Picture left', fr: 'Image à gauche' }, pptx: 'Picture with Caption', justify: 'center' },
53
71
  'image-right': { label: { en: 'Picture right', fr: 'Image à droite' }, pptx: 'Picture with Caption', justify: 'center' },
54
72
  image: { label: { en: 'Picture', fr: 'Image' }, logo: 'none', pptx: 'Blank', numbered: false },
73
+ mindmap: { label: { en: 'Mind map', fr: 'Carte mentale' }, lists: 'mindmap', pptx: 'Title Only' },
74
+ steps: { label: { en: 'Steps', fr: 'Étapes' }, lists: 'steps', pptx: 'Title Only' },
75
+ metrics: { label: { en: 'Key figures', fr: 'Chiffres clés' }, lists: 'metrics', pptx: 'Title Only' },
76
+ timeline: { label: { en: 'Timeline', fr: 'Frise' }, lists: 'timeline', pptx: 'Title Only' },
77
+ process: { label: { en: 'Process', fr: 'Processus' }, lists: 'process', pptx: 'Title Only' },
78
+ agenda: { label: { en: 'Agenda', fr: 'Programme' }, lists: 'agenda', pptx: 'Title Only' },
79
+ pyramid: { label: { en: 'Pyramid', fr: 'Pyramide' }, lists: 'pyramid', pptx: 'Title Only' },
80
+ bento: { label: { en: 'Bento', fr: 'Mosaïque' }, lists: 'bento', pptx: 'Title Only' },
81
+ versus: { label: { en: 'Versus', fr: 'Face à face' }, parts: 'versus', pptx: 'Title Only' },
82
+ matrix: { label: { en: 'Matrix', fr: 'Matrice' }, parts: 'matrix', pptx: 'Title Only' },
55
83
  })).map(([name, given]) => [name, /** @type {Layout} */ (layoutOf(given).layout)])),
56
84
  })
57
85
 
@@ -98,12 +126,14 @@ export function layoutOf(given) {
98
126
  return value
99
127
  }
100
128
  const label = typeof given.label === 'string' || (given.label && typeof given.label === 'object') ? given.label : undefined
129
+ const art = Array.isArray(given.art) && given.art.length <= 8 && given.art.every((/** @type {unknown} */ one) => typeof one === 'string' && NAME.test(one)) ? given.art : undefined
130
+ if (given.art !== undefined && !art) problems.push('art is not 8 names at most')
101
131
  const words = (/** @type {any} */ v) => (typeof v === 'string' ? v.slice(0, 300) : undefined)
102
132
  return {
103
133
  layout: /** @type {Layout} */ ({
104
134
  label, intent: words(given.intent), use_when: words(given.use_when), avoid_when: words(given.avoid_when), slots, budgets, areas: rows, columns, rows: rowTracks,
105
- align: pick('align', 'start'), justify: pick('justify', 'start'), paper: pick('paper', 'paper'), title: pick('title', 'normal'), logo: pick('logo', 'small'),
106
- pptx: PPTX.includes(given.pptx) ? given.pptx : slots.length > 1 ? 'Comparison' : 'Title and Content', numbered: given.numbered !== false,
135
+ align: pick('align', 'start'), justify: pick('justify', 'start'), paper: pick('paper', 'paper'), title: pick('title', 'normal'), logo: pick('logo', 'small'), lists: pick('lists', 'bullets'), parts: pick('parts', 'none'), contents: given.contents === true,
136
+ pptx: PPTX.includes(given.pptx) ? given.pptx : slots.length > 1 ? 'Comparison' : 'Title and Content', numbered: given.numbered !== false, art,
107
137
  }),
108
138
  problems,
109
139
  }
@@ -123,14 +153,15 @@ function clean(given) {
123
153
  /**
124
154
  * Design tokens as the W3C's Design Tokens format (DTCG 2025.10) writes them, read into a kit's:
125
155
  * `color.light` and `color.dark` groups (bg, surface, fg, muted, accent) or flat colours, `font.sans`
126
- * and the like; each `$value` a hex string or a colour object with its `hex`. Tokens already flat
156
+ * and the like; each `$value` a colour string or a colour object with its `hex` and `alpha`. Tokens already flat
127
157
  * pass through. @param {any} given @returns {Record<string, any>}
128
158
  */
129
159
  export function tokensFrom(given) {
130
160
  if (!given || typeof given !== 'object') return {}
131
161
  if (!JSON.stringify(given).includes('"$value"')) return given
132
162
  const value = (/** @type {any} */ t) => (t && typeof t === 'object' && '$value' in t ? t.$value : t)
133
- const colour = (/** @type {any} */ t) => { const v = value(t); return typeof v === 'string' ? v : typeof v?.hex === 'string' ? v.hex : undefined }
163
+ // A colour object's alpha kept, as #rrggbbaa.
164
+ const colour = (/** @type {any} */ t) => { const v = value(t); return typeof v === 'string' ? v : typeof v?.hex === 'string' ? `${v.hex}${typeof v.alpha === 'number' && v.alpha < 1 ? Math.round(Math.max(0, v.alpha) * 255).toString(16).padStart(2, '0') : ''}` : undefined }
134
165
  const face = (/** @type {any} */ t) => { const v = value(t); return Array.isArray(v) ? v.map(String) : typeof v === 'string' ? [v] : undefined }
135
166
  const NAMES = /** @type {const} */ ({ bg: 'paper', background: 'paper', paper: 'paper', fg: 'ink', text: 'ink', ink: 'ink', accent: 'primary', primary: 'primary', muted: 'muted', surface: 'soft', soft: 'soft' })
136
167
  const group = (/** @type {any} */ g) => Object.fromEntries(Object.entries(g ?? {}).filter(([k]) => k in NAMES).map(([k, t]) => [NAMES[/** @type {keyof typeof NAMES} */ (k)], colour(t)]).filter(([, v]) => v))
@@ -146,13 +177,13 @@ export function tokensFrom(given) {
146
177
  }
147
178
 
148
179
  /**
149
- * Kits merged, the later over the earlier: their tokens, their dark ones within theirs, and their layouts by name, each
150
- * checked; a layout given as false is taken away, but the default. A logo that isn't a picture inside the kit is dropped.
151
- * @param {...any} kits @returns {{ kit: Kit, problems: string[] }}
180
+ * Kits merged, the later over the earlier: their tokens, their dark ones within theirs, their layouts by name and their
181
+ * pictures, each checked; a layout given as false is taken away, but the default. A logo that isn't a picture inside the
182
+ * kit is dropped. @param {...any} kits @returns {{ kit: Kit, problems: string[] }}
152
183
  */
153
184
  export function mergeKits(...kits) {
154
185
  /** @type {Kit} */
155
- const kit = { name: 'standard', tokens: {}, layouts: { ...STANDARD.layouts } }
186
+ const kit = { name: 'standard', tokens: {}, layouts: { ...STANDARD.layouts }, pictures: {} }
156
187
  const problems = []
157
188
  for (const given of kits) {
158
189
  if (!given || typeof given !== 'object') continue
@@ -161,6 +192,10 @@ export function mergeKits(...kits) {
161
192
  const next = tokensFrom(given.tokens), dark = next.dark && typeof next.dark === 'object' ? { ...kit.tokens.dark, ...next.dark } : kit.tokens.dark
162
193
  kit.tokens = { ...kit.tokens, ...next, ...(dark && { dark }) }
163
194
  }
195
+ for (const [name, picture] of Object.entries(given.pictures ?? {})) {
196
+ if (NAME.test(name) && artOf(picture)) kit.pictures[name] = picture
197
+ else problems.push(`picture ${name}: not a picture inside the kit`)
198
+ }
164
199
  for (const [name, definition] of Object.entries(given.layouts ?? given.types ?? {})) {
165
200
  if (!NAME.test(name)) { problems.push(`layout ${name}: not a-z, 0-9 and -`); continue }
166
201
  if (definition === false) { if (name !== 'default') delete kit.layouts[name]; continue }
@@ -182,6 +217,15 @@ export function labelOf(layout, name, lang) {
182
217
  return layout.label?.[lang] ?? layout.label?.[lang.split('-')[0]] ?? layout.label?.en ?? name
183
218
  }
184
219
 
220
+ /**
221
+ * The layout a slide is drawn with: the one it names, by its name or a name brains give it, else the one its content
222
+ * suits. @param {{ index: number, settings: Record<string, any>, slots: { name: string, text: string }[] }} slide @param {Kit} kit
223
+ */
224
+ export function layoutNameOf(slide, kit) {
225
+ const asked = String(slide.settings.layout ?? '').trim().toLowerCase()
226
+ return kit.layouts[asked] ? asked : kit.layouts[ALIASES[asked]] ? ALIASES[asked] : inferLayout(slide)
227
+ }
228
+
185
229
  /**
186
230
  * The layout a slide that names none is given, from what it holds, as Pandoc's PowerPoint writer
187
231
  * chooses: the first slide is the cover; a heading alone, a section; slots beyond the first, the
package/src/look.js CHANGED
@@ -41,12 +41,12 @@ export function tokensOf(settings, project = {}) {
41
41
  function clean(given) {
42
42
  /** @type {Partial<Tokens>} */
43
43
  const kept = {}
44
- for (const key of /** @type {const} */ (['paper', 'ink', 'primary', 'muted', 'soft'])) if (typeof given[key] === 'string' && hex(given[key])) kept[key] = given[key]
45
- if (given.dark && typeof given.dark === 'object') kept.dark = Object.fromEntries(Object.entries(given.dark).filter(([, v]) => typeof v === 'string' && hex(v)))
44
+ for (const key of /** @type {const} */ (['paper', 'ink', 'primary', 'muted', 'soft'])) if (typeof given[key] === 'string' && rgbOf(given[key])) kept[key] = given[key]
45
+ if (given.dark && typeof given.dark === 'object') kept.dark = Object.fromEntries(Object.entries(given.dark).filter(([, v]) => typeof v === 'string' && rgbOf(v)))
46
46
  if (given.fonts && Array.isArray(given.fonts.heading) && Array.isArray(given.fonts.body)) kept.fonts = { heading: given.fonts.heading.map(String), body: given.fonts.body.map(String) }
47
47
  if (typeof given.voice === 'string') kept.voice = given.voice
48
48
  if (typeof given.radius === 'number') kept.radius = given.radius
49
- if (Array.isArray(given.series) && given.series.length && given.series.every((/** @type {unknown} */ c) => typeof c === 'string' && hex(c))) kept.series = given.series.slice(0, 6)
49
+ if (Array.isArray(given.series) && given.series.length && given.series.every((/** @type {unknown} */ c) => typeof c === 'string' && rgbOf(c))) kept.series = given.series.slice(0, 6)
50
50
  return kept
51
51
  }
52
52
 
@@ -55,30 +55,36 @@ function clean(given) {
55
55
  * @param {Tokens} tokens @param {'light' | 'dark'} scheme @returns {Scheme}
56
56
  */
57
57
  export function schemeOf(tokens, scheme) {
58
- const light = hex(tokens.paper) ?? [255, 253, 248], dark = hex(tokens.ink) ?? [29, 27, 22]
58
+ // A see-through colour is seen over the paper it is on.
59
+ const light = rgbOf(tokens.paper) ?? [255, 253, 248], dark = rgbOf(tokens.ink, light) ?? [29, 27, 22]
59
60
  // A kit's own dark colours where it gives them (a design system's dark mode), else worked out.
60
61
  const own = scheme === 'dark' ? tokens.dark ?? {} : {}
61
- const paper = round(hex(own.paper ?? '') ?? (scheme === 'dark' ? mix(dark, [0, 0, 0], 0.35) : light))
62
- const ink = round(hex(own.ink ?? '') ?? (scheme === 'dark' ? mix(light, [255, 255, 255], 0.2) : dark))
63
- const primary = hex(own.primary ?? tokens.primary) ?? [247, 197, 49]
62
+ const paper = round(rgbOf(own.paper ?? '', scheme === 'dark' ? [0, 0, 0] : [255, 255, 255]) ?? (scheme === 'dark' ? mix(dark, [0, 0, 0], 0.35) : light))
63
+ const ink = round(rgbOf(own.ink ?? '', paper) ?? (scheme === 'dark' ? mix(light, [255, 255, 255], 0.2) : dark))
64
+ const primary = rgbOf(own.primary ?? tokens.primary, paper) ?? [247, 197, 49]
64
65
  // The primary as a fill keeps its own colour; as words or marks it is made to read on the paper.
65
66
  const text = readable(primary, paper, 4.5)
66
67
  const onPrimary = contrast(ink, primary) >= contrast(paper, primary) ? ink : paper
67
68
  // A chart's colours: the kit's own (a template's accents), else the primary turned round the wheel; each made to read at 3:1.
68
- const given = (tokens.series ?? []).map(hex).filter((c) => c !== null)
69
+ const given = (tokens.series ?? []).map((c) => rgbOf(c, paper)).filter((c) => c !== null)
69
70
  const series = [0, 1, 2, 3, 4, 5].map((n) => readable(/** @type {number[]} */ (given[n % (given.length || 1)] ?? rotate(primary, n * 57)), paper, 3))
70
71
  return {
71
- paper: css(paper), ink: css(ink), muted: css(readable(round(hex(own.muted ?? (scheme === 'light' ? tokens.muted ?? '' : '')) ?? mix(ink, paper, 0.38)), paper, 4.5)), line: css(mix(ink, paper, 0.82)),
72
- primary: css(text), onPrimary: css(onPrimary), soft: css(hex(own.soft ?? (scheme === 'light' ? tokens.soft ?? '' : '')) ?? mix(primary, paper, 0.78)), series: series.map(css),
72
+ paper: css(paper), ink: css(ink), muted: css(readable(round(rgbOf(own.muted ?? (scheme === 'light' ? tokens.muted ?? '' : ''), paper) ?? mix(ink, paper, 0.38)), paper, 4.5)), line: css(mix(ink, paper, 0.82)),
73
+ primary: css(text), onPrimary: css(onPrimary), soft: css(rgbOf(own.soft ?? (scheme === 'light' ? tokens.soft ?? '' : ''), paper) ?? mix(primary, paper, 0.78)), series: series.map(css),
73
74
  }
74
75
  }
75
76
 
76
- /** @param {string} value @returns {[number, number, number] | null} */
77
- export function hex(value) {
78
- const m = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(String(value).trim())
79
- if (!m) return null
80
- const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]
81
- return /** @type {[number, number, number]} */ ([0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)))
77
+ /**
78
+ * A colour as deck reads one, as red, green and blue: `#rgb` or `#rrggbb` (`#rgba`, `#rrggbbaa` with an alpha), `rgb()` or
79
+ * `rgba()` as CSS writes them, by commas or spaces, the alpha after a comma or a slash; one see-through is seen over `under`,
80
+ * white unless said. @param {string} value @param {number[]} [under] @returns {[number, number, number] | null}
81
+ */
82
+ export function rgbOf(value, under = [255, 255, 255]) {
83
+ const text = String(value).trim(), digits = /^#?([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(text)?.[1], css = /^rgba?\(([^)]*)\)$/i.exec(text)?.[1].split(/[\s,/]+/).filter(Boolean)
84
+ const share = (/** @type {string} */ part, /** @type {number} */ whole) => (part.endsWith('%') ? (parseFloat(part) / 100) * whole : Number(part))
85
+ const [r, g, b, a = 1] = digits ? (digits.length < 5 ? [...digits].map((c) => c + c) : /** @type {string[]} */ (digits.match(/../g))).map((h, i) => parseInt(h, 16) / (i > 2 ? 255 : 1))
86
+ : css?.length === 3 || css?.length === 4 ? css.map((part, i) => share(part, i > 2 ? 1 : 255)) : []
87
+ return r !== undefined && [r, g, b].every((c) => c >= 0 && c <= 255) && a >= 0 && a <= 1 ? round(mix(under, [r, g, b], a)) : null
82
88
  }
83
89
 
84
90
  /** @param {number[]} rgb */
package/src/mindmap.js ADDED
@@ -0,0 +1,208 @@
1
+ // A mind map, from lists as a slide holds them: each item a node, its indentation its branching,
2
+ // every list on the slide merged into one tree, as items of the same words at the same place are
3
+ // one node, under one root (the item the lists share, else the slide's heading). Laid out as maps
4
+ // are drawn by hand: the root in the middle, its branches shared out to either side, the first on
5
+ // the side a line ends, clockwise, each branch a tidy tree going outwards. Pure, but for
6
+ // `watchMap`, which measures a map's words on its slide and places them, again whenever their size
7
+ // or its box's changes, in em of the map's own type, so it holds at any size.
8
+
9
+ /** @typedef {{ text: string, start: number, end: number, children: Item[], blocks?: { lang: string, text: string }[], step?: boolean, element?: HTMLElement }} Item */
10
+ /** @typedef {{ w: number, h: number, children: Box[], x?: number, y?: number, side?: number, span?: number, inner?: number }} Box */
11
+ /** @typedef {{ branch: number, depth: number, x1: number, y1: number, x2: number, y2: number }} Line */
12
+
13
+ const ITEM = /^([ \t]*)(?:[-*+]|\d{1,9}[.)])(?:[ \t]+(?:\[[ xX]\][ \t]+)?|$)/
14
+
15
+ /**
16
+ * A list's items as a tree, by their indentation, as written: each item's words where they are in the source, from
17
+ * `start` (where the list's own text starts), a line that is no item going on with the item above it, a fenced block
18
+ * under an item (a chart, a diagram) that item's to draw.
19
+ * @param {string} raw @param {number} start @returns {Item[]}
20
+ */
21
+ export function itemsOf(raw, start) {
22
+ /** @type {Item[]} */
23
+ const top = []
24
+ /** @type {{ indent: number, item: Item }[]} */
25
+ const open = []
26
+ let at = start
27
+ /** @type {{ fence: string, indent: number, lang: string, lines: string[] } | null} */
28
+ let fenced = null
29
+ for (const line of raw.split('\n')) {
30
+ const from = at
31
+ at += line.length + 1
32
+ if (fenced) {
33
+ if (line.trimStart().startsWith(fenced.fence) && !line.trim().slice(fenced.fence.length).trim()) {
34
+ const item = /** @type {{ item: Item }} */ (open.at(-1)).item
35
+ ;(item.blocks ??= []).push({ lang: fenced.lang, text: fenced.lines.join('\n') })
36
+ fenced = null
37
+ } else fenced.lines.push(line.slice(Math.min(fenced.indent, /^[ \t]*/.exec(line)?.[0].length ?? 0)))
38
+ continue
39
+ }
40
+ const fence = /^([ \t]*)(`{3,}|~{3,})(.*)$/.exec(line)
41
+ if (fence && open.length) { fenced = { fence: fence[2], indent: fence[1].length, lang: fence[3].trim(), lines: [] }; continue }
42
+ const marker = ITEM.exec(line)
43
+ if (marker) {
44
+ const indent = marker[1].replace(/\t/g, ' ').length
45
+ const item = { text: line.slice(marker[0].length).trim(), start: from + marker[0].length, end: from + line.trimEnd().length, children: [] }
46
+ while (open.length && /** @type {{ indent: number }} */ (open.at(-1)).indent >= indent) open.pop()
47
+ ;(open.at(-1)?.item.children ?? top).push(item)
48
+ open.push({ indent, item })
49
+ } else if (line.trim() && open.length) {
50
+ const item = /** @type {{ item: Item }} */ (open.at(-1)).item
51
+ item.text = `${item.text} ${line.trim()}`.trim()
52
+ item.end = from + line.trimEnd().length
53
+ }
54
+ }
55
+ return top
56
+ }
57
+
58
+ /** An item's words as they are compared: no emphasis, no case, one space. @param {string} text */
59
+ export const keyOf = (text) => text.normalize('NFC').replace(/[*_`~]/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
60
+
61
+ /**
62
+ * Items merged: of siblings with the same words, the first keeps the others' children, at every level; a step if any
63
+ * was one. Items without words stay apart. @param {Item[]} items @returns {Item[]}
64
+ */
65
+ export function mergeItems(items) {
66
+ /** @type {Item[]} */
67
+ const out = []
68
+ /** @type {Map<string, Item>} */
69
+ const seen = new Map()
70
+ for (const item of items) {
71
+ const key = keyOf(item.text), same = key ? seen.get(key) : undefined
72
+ if (same) { same.children.push(...item.children); same.step = same.step || item.step; continue }
73
+ const copy = { ...item, children: [...item.children] }
74
+ if (key) seen.set(key, copy)
75
+ out.push(copy)
76
+ }
77
+ for (const item of out) item.children = mergeItems(item.children)
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * A map laid out, its boxes' sizes given (in em): the root's centre at the middle, its branches shared out to the end
83
+ * side (top to bottom) and the start side (bottom to top) so the two are as tall as they can be alike, each child a gap
84
+ * beyond its parent, siblings stacked and centred on it, spaced out further (up to four times) where the map would be
85
+ * flatter than its box (`aspect`, its width over its height). Each box gets its `x` and `y` (its top start corner, from the
86
+ * map's top start corner, mirrored if `rtl`) and its `side` (1 right of the root, -1 left); gives the map's size and its
87
+ * lines, each from a parent's side to its child's.
88
+ * @param {Box} root @param {{ rtl?: boolean, aspect?: number }} [how] @returns {{ width: number, height: number, lines: Line[] }}
89
+ */
90
+ export function layoutMap(root, { rtl = false, aspect = 0 } = {}) {
91
+ let laid = layoutAt(root, rtl, 1)
92
+ if (aspect > 0 && laid.width / laid.height > aspect && layoutAt(root, rtl, 4).height < laid.width / aspect) laid = layoutAt(root, rtl, 4)
93
+ else if (aspect > 0 && laid.width / laid.height > aspect) {
94
+ let low = 1, high = 4
95
+ for (let n = 0; n < 12; n++) { const mid = (low + high) / 2; if (layoutAt(root, rtl, mid).height < laid.width / aspect) low = mid; else high = mid }
96
+ laid = layoutAt(root, rtl, low)
97
+ }
98
+ return laid
99
+ }
100
+
101
+ /** The map laid out with its siblings' spaces so many times the usual. @param {Box} root @param {boolean} rtl @param {number} spread */
102
+ function layoutAt(root, rtl, spread) {
103
+ const gap = (/** @type {number} */ depth) => (depth <= 1 ? 2.4 : 1.5)
104
+ const space = (/** @type {number} */ depth) => (depth <= 1 ? 0.8 * spread : 0.3 * Math.sqrt(spread))
105
+ const measure = (/** @type {Box} */ box, /** @type {number} */ depth) => {
106
+ box.inner = box.children.reduce((sum, child) => sum + measure(child, depth + 1), 0) + space(depth + 1) * Math.max(0, box.children.length - 1)
107
+ box.span = Math.max(box.h, box.inner)
108
+ return box.span
109
+ }
110
+ /** @type {Line[]} */
111
+ const lines = []
112
+ // A box at its near edge `x`, in a band from `top`, going out to `side` (1 the end, -1 the start).
113
+ const place = (/** @type {Box} */ box, /** @type {number} */ x, /** @type {number} */ top, /** @type {number} */ side, /** @type {number} */ depth, /** @type {number} */ branch) => {
114
+ const middle = top + /** @type {number} */ (box.span) / 2
115
+ box.x = side > 0 ? x : x - box.w
116
+ box.side = rtl ? -side : side
117
+ box.y = middle - box.h / 2
118
+ let y = middle - /** @type {number} */ (box.inner) / 2
119
+ const far = x + side * box.w
120
+ for (const child of box.children) {
121
+ const near = far + side * gap(depth + 1)
122
+ place(child, near, y, side, depth + 1, branch)
123
+ lines.push({ branch, depth: depth + 1, x1: far, y1: middle, x2: near, y2: y + /** @type {number} */ (child.span) / 2 })
124
+ y += /** @type {number} */ (child.span) + space(depth + 1)
125
+ }
126
+ }
127
+ for (const child of root.children) measure(child, 1)
128
+ root.span = root.h
129
+ root.side = 0
130
+ root.x = -root.w / 2
131
+ root.y = -root.h / 2
132
+ // The branches to the end side, then the rest to the start: the split that makes the two sides nearest in height.
133
+ const tall = (/** @type {Box[]} */ some) => some.reduce((sum, b) => sum + /** @type {number} */ (b.span), 0) + space(1) * Math.max(0, some.length - 1)
134
+ const count = root.children.length
135
+ let split = count
136
+ for (let k = Math.ceil(count / 2); k <= count; k++) if (Math.max(tall(root.children.slice(0, k)), tall(root.children.slice(k))) < Math.max(tall(root.children.slice(0, split)), tall(root.children.slice(split)))) split = k
137
+ for (const [side, some] of /** @type {[number, Box[]][]} */ ([[1, root.children.slice(0, split)], [-1, root.children.slice(split).reverse()]])) {
138
+ let y = -tall(some) / 2
139
+ for (const child of some) {
140
+ const near = side * (root.w / 2 + gap(1))
141
+ place(child, near, y, side, 1, root.children.indexOf(child))
142
+ lines.push({ branch: root.children.indexOf(child), depth: 1, x1: side * root.w / 2, y1: 0, x2: near, y2: y + /** @type {number} */ (child.span) / 2 })
143
+ y += /** @type {number} */ (child.span) + space(1)
144
+ }
145
+ }
146
+ // From the map's top start corner.
147
+ /** @type {Box[]} */
148
+ const all = []
149
+ const walk = (/** @type {Box} */ box) => { all.push(box); box.children.forEach(walk) }
150
+ walk(root)
151
+ const left = Math.min(...all.map((b) => /** @type {number} */ (b.x))), right = Math.max(...all.map((b) => /** @type {number} */ (b.x) + b.w))
152
+ const top = Math.min(...all.map((b) => /** @type {number} */ (b.y))), bottom = Math.max(...all.map((b) => /** @type {number} */ (b.y) + b.h))
153
+ const width = right - left
154
+ const x = (/** @type {number} */ v) => (rtl ? width - (v - left) : v - left)
155
+ for (const box of all) { box.x = rtl ? width - (/** @type {number} */ (box.x) - left) - box.w : /** @type {number} */ (box.x) - left; box.y = /** @type {number} */ (box.y) - top }
156
+ for (const line of lines) Object.assign(line, { x1: x(line.x1), x2: x(line.x2), y1: line.y1 - top, y2: line.y2 - top })
157
+ return { width, height: bottom - top, lines }
158
+ }
159
+
160
+ /** A line as a curve leaving its parent and reaching its child level. @param {Line} line */
161
+ export const curveOf = ({ x1, y1, x2, y2 }) => `M${x1.toFixed(3)} ${y1.toFixed(3)}C${((x1 + x2) / 2).toFixed(3)} ${y1.toFixed(3)} ${((x1 + x2) / 2).toFixed(3)} ${y2.toFixed(3)} ${x2.toFixed(3)} ${y2.toFixed(3)}`
162
+
163
+ /**
164
+ * A map drawn by render.js (`.deck-mindmap`, its plane, its lines' SVG with a group a branch, its tree as lists, each
165
+ * item's words a `.deck-map-node`) placed once it is on a page, and again as its words or its box change size: its
166
+ * words where the layout puts them, its lines, the whole shrunk to its box. @param {HTMLElement} map
167
+ */
168
+ export function watchMap(map) {
169
+ const plane = /** @type {HTMLElement} */ (map.querySelector('.deck-map-plane'))
170
+ const svg = /** @type {SVGSVGElement} */ (plane.querySelector('.deck-map-lines'))
171
+ const root = /** @type {HTMLElement} */ (plane.querySelector('.deck-map-tree > li'))
172
+ const place = () => {
173
+ if (!map.clientWidth || !map.clientHeight) return
174
+ const em = parseFloat(getComputedStyle(plane).fontSize) || 16
175
+ /** @type {[HTMLElement, Box][]} */
176
+ const placed = []
177
+ const boxOf = (/** @type {Element} */ li) => {
178
+ const node = /** @type {HTMLElement} */ (li.firstElementChild)
179
+ const list = li.lastElementChild?.tagName === 'UL' ? [...li.lastElementChild.children] : []
180
+ /** @type {Box} */
181
+ const box = { w: node.offsetWidth / em, h: node.offsetHeight / em, children: list.map(boxOf) }
182
+ placed.push([node, box])
183
+ return box
184
+ }
185
+ const { width, height, lines } = layoutMap(boxOf(root), { rtl: getComputedStyle(map).direction === 'rtl', aspect: map.clientWidth / map.clientHeight })
186
+ // As shares of the plane: a node's own em is its own type's.
187
+ for (const [node, box] of placed) {
188
+ node.style.setProperty('left', `${(/** @type {number} */ (box.x) / width) * 100}%`)
189
+ node.style.setProperty('top', `${(/** @type {number} */ (box.y) / height) * 100}%`)
190
+ node.setAttribute('data-side', box.side === 1 ? 'right' : box.side === -1 ? 'left' : 'middle')
191
+ }
192
+ plane.style.setProperty('width', `${width}em`)
193
+ plane.style.setProperty('height', `${height}em`)
194
+ svg.setAttribute('viewBox', `0 0 ${width.toFixed(3)} ${height.toFixed(3)}`)
195
+ const groups = [...svg.children]
196
+ for (const group of groups) group.replaceChildren()
197
+ for (const line of lines) {
198
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
199
+ path.setAttribute('d', curveOf(line))
200
+ path.setAttribute('data-depth', String(line.depth))
201
+ groups[line.branch]?.append(path)
202
+ }
203
+ plane.style.setProperty('scale', String(Math.min(1, map.clientWidth / (width * em), map.clientHeight / (height * em))))
204
+ }
205
+ const watcher = new ResizeObserver(place)
206
+ watcher.observe(map)
207
+ for (const node of plane.querySelectorAll('.deck-map-node')) watcher.observe(node)
208
+ }