@reekon-tools/boldr-cad 1.3.0 → 1.5.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.
Files changed (252) hide show
  1. package/README.md +21 -4
  2. package/dist/app/edit.d.ts +3 -3
  3. package/dist/app/edit.js +26 -1
  4. package/dist/app/edit.js.map +1 -1
  5. package/dist/app/state.d.ts +3 -5
  6. package/dist/app/state.js +0 -1
  7. package/dist/app/state.js.map +1 -1
  8. package/dist/data/sync.d.ts +1 -1
  9. package/dist/data/sync.js +7 -2
  10. package/dist/data/sync.js.map +1 -1
  11. package/dist/editor/actions.d.ts +8 -0
  12. package/dist/editor/actions.js +149 -169
  13. package/dist/editor/actions.js.map +1 -1
  14. package/dist/editor/elevation.d.ts +4 -1
  15. package/dist/editor/elevation.js +16 -17
  16. package/dist/editor/elevation.js.map +1 -1
  17. package/dist/editor/gestures.d.ts +21 -0
  18. package/dist/editor/gestures.js +92 -14
  19. package/dist/editor/gestures.js.map +1 -1
  20. package/dist/editor/keys.js +2 -2
  21. package/dist/editor/keys.js.map +1 -1
  22. package/dist/editor/linkActions.d.ts +22 -3
  23. package/dist/editor/linkActions.js +95 -13
  24. package/dist/editor/linkActions.js.map +1 -1
  25. package/dist/editor/mode.js +4 -3
  26. package/dist/editor/mode.js.map +1 -1
  27. package/dist/editor/project.js +1 -2
  28. package/dist/editor/project.js.map +1 -1
  29. package/dist/editor/scaleGate.d.ts +13 -0
  30. package/dist/editor/scaleGate.js +27 -0
  31. package/dist/editor/scaleGate.js.map +1 -0
  32. package/dist/editor/selection.js +13 -9
  33. package/dist/editor/selection.js.map +1 -1
  34. package/dist/editor/tooltips.js +9 -0
  35. package/dist/editor/tooltips.js.map +1 -1
  36. package/dist/editor/view3d.d.ts +4 -1
  37. package/dist/editor/view3d.js +6 -2
  38. package/dist/editor/view3d.js.map +1 -1
  39. package/dist/export/pdf.d.ts +5 -2
  40. package/dist/export/pdf.js +12 -8
  41. package/dist/export/pdf.js.map +1 -1
  42. package/dist/export/plan.d.ts +1 -1
  43. package/dist/export/plan.js +14 -9
  44. package/dist/export/plan.js.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.js +1 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/model/demo.d.ts +1 -0
  49. package/dist/model/demo.js +2 -1
  50. package/dist/model/demo.js.map +1 -1
  51. package/dist/model/elevation.d.ts +1 -1
  52. package/dist/model/elevation.js +3 -3
  53. package/dist/model/elevation.js.map +1 -1
  54. package/dist/model/history.js +4 -1
  55. package/dist/model/history.js.map +1 -1
  56. package/dist/model/links.d.ts +4 -1
  57. package/dist/model/links.js +9 -2
  58. package/dist/model/links.js.map +1 -1
  59. package/dist/model/measurement.d.ts +2 -2
  60. package/dist/model/measurement.js +3 -3
  61. package/dist/model/measurement.js.map +1 -1
  62. package/dist/model/openings.d.ts +10 -0
  63. package/dist/model/openings.js +18 -0
  64. package/dist/model/openings.js.map +1 -1
  65. package/dist/model/planGeom.d.ts +37 -2
  66. package/dist/model/planGeom.js +72 -9
  67. package/dist/model/planGeom.js.map +1 -1
  68. package/dist/model/project.js +5 -2
  69. package/dist/model/project.js.map +1 -1
  70. package/dist/model/rooms.d.ts +18 -5
  71. package/dist/model/rooms.js +178 -73
  72. package/dist/model/rooms.js.map +1 -1
  73. package/dist/model/scale.d.ts +30 -0
  74. package/dist/model/scale.js +120 -0
  75. package/dist/model/scale.js.map +1 -0
  76. package/dist/model/settings.d.ts +0 -1
  77. package/dist/model/settings.js +1 -1
  78. package/dist/model/settings.js.map +1 -1
  79. package/dist/model/sketch.d.ts +21 -1
  80. package/dist/model/sketch.js +58 -4
  81. package/dist/model/sketch.js.map +1 -1
  82. package/dist/model/stroke.js +2 -2
  83. package/dist/model/types.d.ts +38 -7
  84. package/dist/react/BottomBar.d.ts +2 -1
  85. package/dist/react/BottomBar.js +94 -25
  86. package/dist/react/BottomBar.js.map +1 -1
  87. package/dist/react/Drawer.js +3 -2
  88. package/dist/react/Drawer.js.map +1 -1
  89. package/dist/react/Duck.js.map +1 -1
  90. package/dist/react/KeypadSheet.js.map +1 -1
  91. package/dist/react/MarkupPanel.js +1 -1
  92. package/dist/react/PlanCanvas.native.js +54 -7
  93. package/dist/react/PlanCanvas.native.js.map +1 -1
  94. package/dist/react/Popovers.js +14 -3
  95. package/dist/react/Popovers.js.map +1 -1
  96. package/dist/react/Rail.js +1 -1
  97. package/dist/react/Sheet.d.ts +2 -2
  98. package/dist/react/Sheet.js.map +1 -1
  99. package/dist/react/barScroll.d.ts +23 -0
  100. package/dist/react/barScroll.js +47 -0
  101. package/dist/react/barScroll.js.map +1 -0
  102. package/dist/react/corners.d.ts +6 -2
  103. package/dist/react/corners.js.map +1 -1
  104. package/dist/react/host.d.ts +7 -0
  105. package/dist/react/host.js +14 -1
  106. package/dist/react/host.js.map +1 -1
  107. package/dist/react/motion.d.ts +1 -1
  108. package/dist/react/motion.js +1 -1
  109. package/dist/react/motion.js.map +1 -1
  110. package/dist/render/draw.d.ts +21 -4
  111. package/dist/render/draw.js +184 -149
  112. package/dist/render/draw.js.map +1 -1
  113. package/dist/render/elevation.js +3 -3
  114. package/dist/render/elevation.js.map +1 -1
  115. package/dist/render/figmaIcons.d.ts +6 -0
  116. package/dist/render/figmaIcons.js +16 -1
  117. package/dist/render/figmaIcons.js.map +1 -1
  118. package/dist/render/hit.d.ts +8 -1
  119. package/dist/render/hit.js +23 -4
  120. package/dist/render/hit.js.map +1 -1
  121. package/dist/render/icons.js +27 -20
  122. package/dist/render/icons.js.map +1 -1
  123. package/dist/render/lens.d.ts +27 -0
  124. package/dist/render/lens.js +41 -0
  125. package/dist/render/lens.js.map +1 -0
  126. package/dist/render/linkTile.d.ts +20 -2
  127. package/dist/render/linkTile.js +476 -246
  128. package/dist/render/linkTile.js.map +1 -1
  129. package/dist/render/snap.d.ts +5 -1
  130. package/dist/render/snap.js +3 -1
  131. package/dist/render/snap.js.map +1 -1
  132. package/dist/render/svgPath.d.ts +29 -0
  133. package/dist/render/svgPath.js +114 -0
  134. package/dist/render/svgPath.js.map +1 -0
  135. package/dist/render/tileIcons.d.ts +18 -0
  136. package/dist/render/tileIcons.js +67 -0
  137. package/dist/render/tileIcons.js.map +1 -0
  138. package/dist/render/view.d.ts +18 -1
  139. package/dist/render/view.js +31 -4
  140. package/dist/render/view.js.map +1 -1
  141. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  142. package/dist/skia/SkiaDrawCtx.js +27 -5
  143. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  144. package/dist/solver/equations.d.ts +6 -0
  145. package/dist/solver/equations.js +23 -6
  146. package/dist/solver/equations.js.map +1 -1
  147. package/dist/solver/gauss-newton.js +6 -1
  148. package/dist/solver/gauss-newton.js.map +1 -1
  149. package/dist/tools/annotate.d.ts +0 -2
  150. package/dist/tools/annotate.js +1 -22
  151. package/dist/tools/annotate.js.map +1 -1
  152. package/dist/tools/chamfer.js +4 -0
  153. package/dist/tools/chamfer.js.map +1 -1
  154. package/dist/tools/dim.d.ts +3 -3
  155. package/dist/tools/dim.js +26 -65
  156. package/dist/tools/dim.js.map +1 -1
  157. package/dist/tools/fillet.js +4 -0
  158. package/dist/tools/fillet.js.map +1 -1
  159. package/dist/tools/freehand.js +1 -4
  160. package/dist/tools/freehand.js.map +1 -1
  161. package/dist/tools/line.js +15 -11
  162. package/dist/tools/line.js.map +1 -1
  163. package/dist/tools/modify.js +6 -1
  164. package/dist/tools/modify.js.map +1 -1
  165. package/dist/tools/registry.d.ts +5 -0
  166. package/dist/tools/registry.js +6 -1
  167. package/dist/tools/registry.js.map +1 -1
  168. package/dist/tools/select.d.ts +19 -0
  169. package/dist/tools/select.js +116 -17
  170. package/dist/tools/select.js.map +1 -1
  171. package/dist/tools/shapes.d.ts +1 -1
  172. package/dist/tools/shapes.js +12 -5
  173. package/dist/tools/shapes.js.map +1 -1
  174. package/dist/tools/survey.d.ts +6 -0
  175. package/dist/tools/survey.js +28 -0
  176. package/dist/tools/survey.js.map +1 -1
  177. package/dist/view3d/build.js +9 -8
  178. package/dist/view3d/build.js.map +1 -1
  179. package/package.json +9 -9
  180. package/src/app/edit.ts +25 -1
  181. package/src/app/state.ts +3 -5
  182. package/src/data/sync.ts +7 -2
  183. package/src/editor/actions.ts +126 -112
  184. package/src/editor/elevation.ts +13 -16
  185. package/src/editor/gestures.ts +83 -13
  186. package/src/editor/keys.ts +2 -2
  187. package/src/editor/linkActions.ts +90 -13
  188. package/src/editor/mode.ts +4 -3
  189. package/src/editor/project.ts +1 -2
  190. package/src/editor/scaleGate.ts +32 -0
  191. package/src/editor/selection.ts +13 -7
  192. package/src/editor/tooltips.ts +8 -0
  193. package/src/editor/view3d.ts +6 -2
  194. package/src/export/pdf.ts +7 -4
  195. package/src/export/plan.ts +14 -10
  196. package/src/index.ts +1 -1
  197. package/src/model/demo.ts +2 -1
  198. package/src/model/elevation.ts +4 -4
  199. package/src/model/history.ts +4 -1
  200. package/src/model/links.ts +8 -2
  201. package/src/model/measurement.ts +3 -3
  202. package/src/model/openings.ts +18 -0
  203. package/src/model/planGeom.ts +70 -10
  204. package/src/model/project.ts +6 -2
  205. package/src/model/rooms.ts +151 -63
  206. package/src/model/scale.ts +105 -0
  207. package/src/model/settings.ts +1 -2
  208. package/src/model/sketch.ts +48 -4
  209. package/src/model/stroke.ts +2 -2
  210. package/src/model/types.ts +38 -7
  211. package/src/react/BottomBar.tsx +114 -29
  212. package/src/react/Drawer.tsx +14 -10
  213. package/src/react/Duck.tsx +3 -3
  214. package/src/react/KeypadSheet.tsx +2 -2
  215. package/src/react/MarkupPanel.tsx +1 -1
  216. package/src/react/PlanCanvas.native.tsx +60 -6
  217. package/src/react/Popovers.tsx +24 -9
  218. package/src/react/Rail.tsx +1 -1
  219. package/src/react/Sheet.tsx +3 -3
  220. package/src/react/barScroll.ts +57 -0
  221. package/src/react/corners.ts +6 -3
  222. package/src/react/host.ts +14 -1
  223. package/src/react/motion.tsx +6 -6
  224. package/src/render/draw.ts +156 -116
  225. package/src/render/elevation.ts +5 -4
  226. package/src/render/figmaIcons.ts +17 -1
  227. package/src/render/hit.ts +21 -4
  228. package/src/render/icons.ts +27 -20
  229. package/src/render/lens.ts +52 -0
  230. package/src/render/linkTile.ts +436 -190
  231. package/src/render/snap.ts +7 -2
  232. package/src/render/svgPath.ts +95 -0
  233. package/src/render/tileIcons.ts +75 -0
  234. package/src/render/view.ts +37 -5
  235. package/src/skia/SkiaDrawCtx.ts +26 -4
  236. package/src/solver/equations.ts +22 -6
  237. package/src/solver/gauss-newton.ts +4 -1
  238. package/src/styles.css +3 -2
  239. package/src/tools/annotate.ts +1 -18
  240. package/src/tools/chamfer.ts +3 -0
  241. package/src/tools/dim.ts +26 -52
  242. package/src/tools/fillet.ts +3 -0
  243. package/src/tools/freehand.ts +1 -3
  244. package/src/tools/line.ts +12 -11
  245. package/src/tools/modify.ts +5 -1
  246. package/src/tools/registry.ts +10 -1
  247. package/src/tools/select.ts +99 -18
  248. package/src/tools/shapes.ts +9 -4
  249. package/src/tools/survey.ts +21 -0
  250. package/src/ui/ctx.ts +4 -3
  251. package/src/ui/drawer.ts +8 -2
  252. package/src/view3d/build.ts +11 -10
@@ -4,16 +4,24 @@ import type { LinkInfo, Note } from '../model/types';
4
4
  import type { DrawCtx } from './ctx';
5
5
  import { strokeMarkup } from './markup';
6
6
  import { smoothRectPath } from './squircle';
7
+ import { drawSvgIcon, svgIcon, type SvgIcon } from './svgPath';
7
8
  import { FONT, TH } from './theme';
8
- import { cvs, leaderPath, linkBox, linkThumb, type ImageLike, type LinkBox, type Xform } from './view';
9
+ import {
10
+ BADGE_CAD_SVG, BADGE_CON_CALC_SVG, BADGE_CONVERSION_SVG, BADGE_FILE_SVG, BADGE_LABEL_SVG, BADGE_NOTE_SVG, KEY_CAMERA_SVG,
11
+ KEY_MEASUREMENT_SVG, KEY_SKETCH_SVG,
12
+ } from './tileIcons';
13
+ import { cvs, leaderPath, linkBox, linkThumb, requestDraw, type ImageLike, type LinkBox, type Xform } from './view';
9
14
 
10
15
  /*
11
- A link's tile on the plan: a square card for one of the job's files (a photo's picture edge to edge; anything else's
12
- picture, or an icon for its kind, above its name) or measurements (its value and name), and its leader — a line in the markup's look from the card's edge to
13
- what it points at. The card is laid out once at LINK_TILE_PX and scaled to its size on the drawing, so its shapes and
14
- text are the same at every step of a pinch (kept, on a device). Small on the screen it shows less: its picture or
15
- icon alone, then a pin of a fixed size. Where it is on the screen (and its handles) is view.ts's, and picking it
16
- hit.ts's.
16
+ A link's tile on the plan, as Figma draws an object tile (657:8732): a square card filled with what the object looks
17
+ like (its picture, a note's text, a conversion's screen), a grey bar along its foot with the object's badge — its
18
+ type's colour and icon, as the add-object sheet has them — and its name. A photo or a sketch with its picture is that
19
+ picture alone, edge to edge. Selected, a yellow ring round its edge; otherwise a grey hairline inside it. The card is
20
+ laid out once at LINK_TILE_PX (Figma's 120 tile, scaled) and scaled to its size on the drawing; its bar, the bar's
21
+ badge and name, and the badge in the middle of a card with nothing to show stay one size on the screen (BAR_K), and a
22
+ wider card shows more of its name. What it's made of is kept, on a device: a pinch makes a few new shapes, not one a
23
+ step (screenW, cornerOf). Small on the screen it shows less: its picture or its badge alone, then a pin of a fixed
24
+ size. Where it is on the screen (and its handles) is view.ts's, and picking it hit.ts's.
17
25
  */
18
26
 
19
27
  export interface LinkDrawOpts {
@@ -25,6 +33,7 @@ export interface LinkDrawOpts {
25
33
 
26
34
  /** Draw tile `n` and its leader. */
27
35
  export function drawLink(c: DrawCtx, T: Xform, n: Note, o: LinkDrawOpts): void {
36
+ if (!watchingFonts) watchFonts();
28
37
  const b = linkBox(T, n), lp = leaderPath(T, n, b);
29
38
  c.save();
30
39
  c.globalAlpha = o.alpha;
@@ -41,8 +50,44 @@ export function drawLink(c: DrawCtx, T: Xform, n: Note, o: LinkDrawOpts): void {
41
50
  */
42
51
  const screenW = (px: number, k: number): number => Math.pow(2, Math.round(Math.log2(px / k) * 4) / 4);
43
52
 
44
- /** The layout's side, and its rounding; how wide a selected tile's ring is outside its edge (pt). */
45
- const L = LINK_TILE_PX, R = 12, SELECT_W = 3;
53
+ /** Figma's 120 tile in the layout's units: every measure of the frame × F. */
54
+ const L = LINK_TILE_PX, F = L / 120;
55
+ /** The card's corners (10, smoothed 60% as every corner in the editor); how wide a selected tile's ring is (pt). */
56
+ const R = 10 * F, SELECT_W = 3;
57
+ /** The bar along the card's foot: 28 tall. */
58
+ const FOOT_H = 28 * F;
59
+ /** The badge in it (20, corners 6), 4 in from the card's side and the bar's top; the name 5.37 past it, 4 short of the far side. */
60
+ const BADGE = 20 * F, BADGE_IN = 4 * F, NAME_X = 29.366 * F, NAME_END = 4 * F;
61
+ /** The name: Inter Medium 10 in the frame, its baseline 18 below the bar's top. */
62
+ const NAME_PX = 10 * F, NAME_BASE = 18 * F;
63
+ /**
64
+ * The bar, its badge and its name are one size on the screen at any zoom (the user's call, 2026-10-26: "on zoom
65
+ * nothing scales but the text — the bottom banner stays the same height, and so does the icon"): the frame's, ×
66
+ * BAR_SCALE. Its name is 12 (the user's call, 2026-10-26: "make it 12 px font … adjust the banner and icon size if
67
+ * needed to scale properly"), so the bar is 33.6 and its badge 24, as the frame has them to its 10. The card and what
68
+ * it shows grow with the drawing; a name shows more of itself as the card widens. BAR_K: the card's scale at which its
69
+ * layout's bar is that size.
70
+ */
71
+ const BAR_SCALE = 1.2, BAR_K = BAR_SCALE / F;
72
+ /**
73
+ * The tile's type: Figma's Inter. BOLDR Web's pages load it; on a device the host's font is drawn whatever is named
74
+ * here (BOLDR passes Inter). Anywhere without it, the editor's own.
75
+ */
76
+ const TILE_FONT = '"Inter",' + FONT;
77
+
78
+ /**
79
+ * The card's corners at scale `k`: R, up to the card at BAR_K (144 across); bigger than that, about as big on the
80
+ * screen as they are there (12: the frame's 10, as the bar is the frame's × BAR_SCALE), so they never reach into its
81
+ * badge or its name's ends — in steps, as screenW's, so a pinch makes a few shapes and not one a step: 11 to 13.1.
82
+ */
83
+ const cornerOf = (k: number): number => (k <= BAR_K ? R : R * Math.pow(2, Math.round(Math.log2(BAR_K / k) * 4) / 4));
84
+
85
+ /**
86
+ * The tile's own colours (Figma's, the same in both themes: a tile is a dark card on any canvas): the card under its
87
+ * picture (General/Grey - 1), the bar (Grey - 2), its hairlines (Grey - 3), quiet text (Grey - 4), and the light grey a
88
+ * calculator's diagram sits on (Light Grey - 2).
89
+ */
90
+ const TILE = { card: '#2B2C2E', bar: '#424345', line: '#626364', quiet: '#9FA0A2', name: '#FFFFFF', paper: '#E8EAED' } as const;
46
91
 
47
92
  /** A rounded rectangle's path, one shape for each size (as draw.ts's rr: kept by an engine that can). */
48
93
  function rr(c: DrawCtx, x: number, y: number, w: number, h: number, r: number): void {
@@ -50,91 +95,398 @@ function rr(c: DrawCtx, x: number, y: number, w: number, h: number, r: number):
50
95
  else smoothRectPath(c, x, y, w, h, r);
51
96
  }
52
97
 
53
- /** The kind of thing a tile is for, as its icon shows it. */
54
- type Glyph = 'photo' | 'pdf' | 'document' | 'note' | 'calculator' | 'label' | 'drawing' | 'tape' | 'removed';
55
- const glyphOf = (n: Note, info?: LinkInfo): Glyph => {
56
- if (info?.exists === false) return 'removed';
57
- if (n.link!.kind === 'measurement') return 'tape';
58
- const t = info?.fileType ?? n.link!.fileType;
59
- return t === 'photo' || t === 'pdf' || t === 'note' || t === 'calculator' || t === 'label' || t === 'drawing' ? t : 'document';
98
+ /**
99
+ * Clip to the card above its bar, or (`bar`) to the bar — the bar drawn at its scale `u` within the card's. Described in
100
+ * the bar's own scale, where it's one size whatever the zoom, so it's one shape kept, never one a step of a pinch.
101
+ */
102
+ function clipToBar(c: DrawCtx, u: number, bar: boolean): void {
103
+ // (far past the card every way but the one edge that counts, the bar's top: put where the shape starts, so it's
104
+ // exactly there however the rest rounds. The biggest card, 480" at the closest zoom (400 px an inch), is 128,000 of
105
+ // the bar's units across: LINK_MAX_IN × ZOOM_MAX / BAR_SCALE.)
106
+ const far = 1e6, d = bar ? far : -far;
107
+ const draw = (cc: DrawCtx, X: number, Y: number) => {
108
+ cc.beginPath(); cc.moveTo(X - far, Y); cc.lineTo(X + far, Y); cc.lineTo(X + far, Y + d); cc.lineTo(X - far, Y + d); cc.closePath();
109
+ };
110
+ c.translate(0, L); c.scale(u, u); c.translate(0, -L);
111
+ if (c.shape) c.shape('bar clip ' + bar, 0, L - FOOT_H, draw);
112
+ else draw(c, 0, L - FOOT_H);
113
+ c.clip();
114
+ c.translate(0, L); c.scale(1 / u, 1 / u); c.translate(0, -L);
115
+ }
116
+
117
+ /** What a tile is, by the object it's for: Figma's tile types (and a sketch, a measurement: no tile there). */
118
+ export type TileType = 'image' | 'sketch' | 'file' | 'concalc' | 'conversion' | 'note' | 'cad' | 'label' | 'measurement';
119
+
120
+ /** The host's kind of file (LinkInfo.fileType) as a tile type: a PDF or anything unknown is a file. */
121
+ export function tileTypeOf(n: Note, info?: LinkInfo): TileType {
122
+ if (n.link!.kind === 'measurement') return 'measurement';
123
+ switch (info?.fileType ?? n.link!.fileType) {
124
+ case 'photo': return 'image';
125
+ case 'drawing': return 'sketch';
126
+ case 'calculator': return 'concalc';
127
+ case 'conversion': return 'conversion';
128
+ case 'note': return 'note';
129
+ case 'label': return 'label';
130
+ case 'cad': return 'cad';
131
+ default: return 'file';
132
+ }
133
+ }
134
+
135
+ /**
136
+ * A badge: its hue, its icon (render/tileIcons.ts) and where the icon sits in a badge 20 across. Figma's for six types
137
+ * ("Object Tile Icons" 704:23628, glyph boxes as drawn there); the rest from the add-object sheet, their glyphs as big
138
+ * in the badge as those are (about 13 of its 20).
139
+ */
140
+ interface Badge { hue: string; icon: SvgIcon; key: string; x: number; y: number; size: number }
141
+ const badge = (hue: string, svg: string, key: string, x: number, y: number, size: number): Badge => ({ hue, icon: svgIcon(svg), key, x, y, size });
142
+ export const BADGES: Record<TileType, Badge> = {
143
+ file: badge('#E93C3E', BADGE_FILE_SVG, 'tile file', 5, 3.6034, 12.7932),
144
+ concalc: badge('#BD30A8', BADGE_CON_CALC_SVG, 'tile concalc', 2.9285, 4.5, 14.1429),
145
+ conversion: badge('#505DC1', BADGE_CONVERSION_SVG, 'tile conversion', 4.0002, 4.7068, 12),
146
+ note: badge('#0DBF4A', BADGE_NOTE_SVG, 'tile note', 3.5, 4, 13),
147
+ cad: badge('#23C7C7', BADGE_CAD_SVG, 'tile cad', 3.2557, 2.7557, 14.4887),
148
+ label: badge('#F7781E', BADGE_LABEL_SVG, 'tile label', 3.5, 3.7491, 13),
149
+ // (the sheet's icons are drawn on their 70 tile, their glyph in its middle: the tile scaled so the glyph is 13)
150
+ sketch: badge('#EFB107', KEY_SKETCH_SVG, 'tile sketch', 10 - 29.4 / 2, 10 - 29.4 / 2, 29.4),
151
+ measurement: badge('#0066FF', KEY_MEASUREMENT_SVG, 'tile measurement', 10 - 26.8 / 2, 10 - 26.8 / 2, 26.8),
152
+ image: badge('#000000', KEY_CAMERA_SVG, 'tile camera', 3.5, 10 - (26.8881 * 13) / 36 / 2, 13),
60
153
  };
61
154
 
155
+ /** Badge `t` `size` across, its top left at x, y: its hue (or `hue`), its icon in white in its middle. */
156
+ function drawBadge(c: DrawCtx, t: TileType, x: number, y: number, size: number, hue?: string): void {
157
+ const bd = BADGES[t], s = size / 20;
158
+ rr(c, x, y, size, size, 6 * s);
159
+ c.fillStyle = hue ?? bd.hue; c.fill();
160
+ drawSvgIcon(c, bd.icon, bd.key, x + bd.x * s, y + bd.y * s, bd.size * s);
161
+ }
162
+
163
+ /** Its picture, once loaded (asked for only near the screen: a drawing can link more than are kept). */
164
+ function pictureOf(b: LinkBox, n: Note, info: LinkInfo | undefined): ImageLike | null {
165
+ const gone = info?.exists === false, near = b.x < cvs.W * 2 && b.y < cvs.H * 2 && b.x + b.s > -cvs.W && b.y + b.s > -cvs.H;
166
+ const pic = near && !gone && n.link!.kind === 'file' && info?.thumbUrl ? linkThumb(info.thumbUrl) : null;
167
+ return pic && pic.complete && pic.naturalWidth > 0 ? pic : null;
168
+ }
169
+
170
+ /** What the card is filled with under its preview: a calculator's light grey, a label's bar grey, a conversion's page. */
171
+ function cardFill(type: TileType, pic: ImageLike | null, info: LinkInfo | undefined): string {
172
+ if (pic) return type === 'concalc' ? TILE.paper : type === 'label' ? TILE.bar : TILE.card;
173
+ return type === 'conversion' && info?.text ? CONV.page : TILE.card;
174
+ }
175
+
62
176
  function drawCard(c: DrawCtx, b: LinkBox, n: Note, o: LinkDrawOpts): void {
63
- const k = b.k, t = n.link!, info = o.info, gone = info?.exists === false, glyph = glyphOf(n, info);
177
+ const k = b.k, t = n.link!, info = o.info, gone = info?.exists === false, type = tileTypeOf(n, info);
178
+ const pic = pictureOf(b, n, info), picture = !!pic && (type === 'image' || type === 'sketch');
179
+ const withBar = !picture && b.lod !== 'compact';
180
+ // the bar's scale within the card's: one size on the screen (BAR_K)
181
+ const u = BAR_K / k, r = cornerOf(k);
64
182
  c.save();
65
183
  c.translate(b.x, b.y);
66
184
  c.scale(k, k);
67
- rr(c, 0, 0, L, L, R);
185
+ rr(c, 0, 0, L, L, r);
68
186
  // selected: a yellow ring round the card's edge, the same 3 pt at any size — stroked twice as wide on the card's own
69
187
  // outline before it's filled, so the card covers the inside half (and no new shape is made as it zooms)
70
188
  if (o.selected) { c.lineWidth = screenW(2 * SELECT_W, k); c.strokeStyle = TH.yellow; c.stroke(); }
71
- c.fillStyle = gone ? TH.chip : TH.badge; c.fill();
72
- if (!o.selected) { c.lineWidth = screenW(1, k); c.strokeStyle = TH.badgeline; c.stroke(); }
73
- // (its picture asked for only near the screen: a drawing can link more than are kept)
74
- const near = b.x < cvs.W * 2 && b.y < cvs.H * 2 && b.x + b.s > -cvs.W && b.y + b.s > -cvs.H;
75
- const pic = near && !gone && t.kind === 'file' && info?.thumbUrl ? linkThumb(info.thumbUrl) : null, shown = pic && pic.complete && pic.naturalWidth > 0;
76
- // a photo is its picture, edge to edge — it says what it is (until it's loaded: its icon and name, as any file)
77
- const photo = shown && glyph === 'photo';
78
- if (photo) cover(c, pic, 0, 0, L, L, R);
79
- else if (b.lod === 'compact') {
80
- // the picture or the icon alone
81
- if (shown) cover(c, pic, 6, 6, L - 12, L - 12, R - 4);
82
- else drawGlyph(c, glyph, L / 2, L / 2, 46, TH.ink2);
83
- } else if (t.kind === 'measurement' && !gone) {
84
- // its value, and its name
85
- drawGlyph(c, 'tape', 18, 17, 18, TH.ink2);
86
- const value = info?.text ?? t.text ?? '—';
87
- c.font = '700 18px ' + FONT; c.fillStyle = TH.blue; c.textAlign = 'center'; c.textBaseline = 'middle';
88
- c.fillText(fit(c, value, L - 12), L / 2, 46);
89
- drawName(c, k, info?.name ?? t.name ?? 'Measurement', 75, L - 14, TH.ink2, false);
90
- } else if (gone) {
91
- // the removed mark, and what it was
92
- drawGlyph(c, glyph, L / 2, 30, 28, TH.ink2);
93
- c.font = '700 11px ' + FONT; c.fillStyle = TH.ink2; c.textAlign = 'center'; c.textBaseline = 'middle';
94
- c.fillText('Removed', L / 2, 58);
95
- drawName(c, k, t.name ?? (t.kind === 'file' ? 'File' : 'Measurement'), 79, L - 12, TH.ink2, false);
189
+ c.fillStyle = cardFill(type, pic, gone ? undefined : info); c.fill();
190
+ // everything else within the card's edge: one clip, to the outline just filled (kept as it is, on a device)
191
+ c.clip();
192
+
193
+ if (picture) cover(c, pic!, 0, 0, L, L);
194
+ else if (!withBar) {
195
+ // small: the picture or the badge alone
196
+ if (pic) cover(c, pic, 0, 0, L, L);
197
+ else drawBadge(c, type, L / 4, L / 4, L / 2, gone ? TILE.line : undefined);
96
198
  } else {
97
- // its picture or icon above, its name below
98
- if (shown) cover(c, pic, 6, 6, L - 12, 62, R - 4);
199
+ drawPreview(c, type, pic, u, gone, gone ? undefined : info, t.text);
200
+ drawBar(c, u, k, type, gone, info?.name ?? t.name ?? (t.kind === 'file' ? 'File' : 'Measurement'));
201
+ }
202
+
203
+ // a grey hairline inside its edge, 1 px on the screen (the clip keeps the inside half of a line twice that); selected,
204
+ // the bar keeps its own inside the ring
205
+ if (!o.selected || withBar) {
206
+ if (o.selected) { c.save(); clipToBar(c, u, true); }
207
+ rr(c, 0, 0, L, L, r);
208
+ c.lineWidth = screenW(2, k); c.strokeStyle = TILE.line; c.stroke();
209
+ if (o.selected) c.restore();
210
+ }
211
+ c.restore();
212
+ }
213
+
214
+ /**
215
+ * Where a picture goes that isn't simply the whole card (Figma's frame, at its 120): a label as tall as the card, its
216
+ * foot under the bar, on the bar's grey, as the label printer shows it — kept whole within 8 of the card's sides; a
217
+ * calculator's diagram nine tenths of the card across, on light grey, its top cut off where the frame cuts it and its
218
+ * foot just above the bar. Each is a picture placed whole in that box, so the frame's own (a portrait label, a
219
+ * near-square diagram) land exactly there.
220
+ */
221
+ const LABEL_BOX = [8 * F, 3.45 * F, L - 16 * F, 112 * F] as const;
222
+ const CALC_BOX = [6.244 * F, -8.956 * F, 107.984 * F, 106.86 * F] as const;
223
+
224
+ /**
225
+ * A label's box with the bar at `u`: the frame's, and on a card past the 144 tile, where the bar is less of it than the
226
+ * frame's, as much further down as keeps its foot as far under the bar as the frame's (23.45 of its 28).
227
+ */
228
+ const labelBox = (u: number): readonly [number, number, number, number] =>
229
+ u >= 1 ? LABEL_BOX : [LABEL_BOX[0], LABEL_BOX[1], LABEL_BOX[2], L - 4.55 * F * u - LABEL_BOX[1]];
230
+
231
+ /**
232
+ * What the object looks like, above its bar (drawn at scale `u` within the card's): its picture over the whole card (a
233
+ * label's and a calculator's placed whole, as LABEL_BOX and CALC_BOX), a note's text, a conversion's screen, a
234
+ * measurement's value (`was`: as it was when linked, till the host says) — or, with none of those, its badge, larger,
235
+ * in the middle: one size on the screen, as the bar's, but never more than three quarters of what's above the bar (so
236
+ * smaller on a card under about 98 across). Removed: says so.
237
+ *
238
+ * A label, a calculator's diagram and a conversion's screen are laid out as the frame has them over its bar. On a card
239
+ * under the 144 tile (BAR_K) the bar is more of the card than the frame's, so that layout is drawn smaller, to fit
240
+ * what's above this bar as the frame's fits what's above its own: the diagram's foot and the screen's second unit stay
241
+ * in sight, and a label's foot as far under the bar.
242
+ */
243
+ function drawPreview(c: DrawCtx, type: TileType, pic: ImageLike | null, u: number, gone: boolean, info: LinkInfo | undefined, was?: string): void {
244
+ const foot = L - FOOT_H * u, mid = foot / 2;
245
+ if (gone) {
246
+ c.font = '500 ' + 11 * F + 'px ' + TILE_FONT; c.fillStyle = TILE.quiet; c.textAlign = 'center'; c.textBaseline = 'middle';
247
+ c.fillText('Removed', L / 2, mid);
248
+ return;
249
+ }
250
+ if (pic || (type === 'conversion' && info?.text)) {
251
+ // above the bar only: in Draft a tile is drawn see-through, and what's under the bar would show through it
252
+ c.save();
253
+ clipToBar(c, u, false);
254
+ if (pic && type !== 'label' && type !== 'concalc') cover(c, pic, 0, 0, L, L);
99
255
  else {
100
- const a = c.globalAlpha;
101
- rr(c, 6, 6, L - 12, 62, R - 4);
102
- c.fillStyle = TH.chip; c.globalAlpha = a * 0.5; c.fill(); c.globalAlpha = a;
103
- drawGlyph(c, glyph, L / 2, 37, 34, TH.ink2);
256
+ // (the frame's layout above its bar, in what's above this one: from its top middle)
257
+ const s = Math.min(1, foot / (L - FOOT_H));
258
+ if (s < 1) { c.translate(L / 2, 0); c.scale(s, s); c.translate(-L / 2, 0); }
259
+ if (!pic) drawConversion(c, info!.text!);
260
+ else contain(c, pic, ...(type === 'label' ? labelBox(u) : CALC_BOX));
104
261
  }
105
- drawName(c, k, info?.name ?? t.name ?? 'File', 81, L - 12, TH.ink, true);
262
+ c.restore();
263
+ return;
106
264
  }
265
+ if (type === 'note' && info?.text) { drawNoteText(c, info.text, foot); return; }
266
+ if (type === 'measurement') {
267
+ c.font = '700 ' + 22 * F + 'px ' + TILE_FONT; c.fillStyle = TILE.name; c.textAlign = 'center'; c.textBaseline = 'middle';
268
+ c.fillText(fit(c, info?.text ?? was ?? '—', L - 16 * F), L / 2, mid);
269
+ return;
270
+ }
271
+ // (the frame's 40, one size on the screen as the bar's badge is, and as much bigger than the frame's: no bigger than
272
+ // three quarters of what's above the bar — drawn at 40 and scaled, so its shapes are the same at every step of a pinch)
273
+ const s = 40 * F, q = Math.min(u, (0.75 * foot) / s);
274
+ c.save();
275
+ c.translate(L / 2, mid); c.scale(q, q);
276
+ drawBadge(c, type, -s / 2, -s / 2, s);
277
+ c.restore();
278
+ }
279
+
280
+ /**
281
+ * The bar along the card's foot (Figma: 28 tall, General/Grey - 2, a Grey - 3 hairline along its top), drawn at its own
282
+ * scale `u` within the card's (one size on the screen: BAR_K), anchored at the card's foot: its badge, then the name —
283
+ * white, Medium, on one line, cut short with an ellipsis. Once the object's gone, a grey badge.
284
+ */
285
+ function drawBar(c: DrawCtx, u: number, k: number, type: TileType, gone: boolean, name: string): void {
286
+ c.save();
287
+ c.translate(0, L); c.scale(u, u); c.translate(0, -L);
288
+ const W = L / u, top = L - FOOT_H;
289
+ c.fillStyle = TILE.bar; c.fillRect(0, top, W, FOOT_H);
290
+ c.fillStyle = TILE.line; c.fillRect(0, top, W, screenW(1, k * u));
291
+ drawBadge(c, type, BADGE_IN, top + BADGE_IN, BADGE, gone ? TILE.line : undefined);
292
+ c.font = '500 ' + NAME_PX + 'px ' + TILE_FONT; c.fillStyle = TILE.name; c.textAlign = 'left'; c.textBaseline = 'alphabetic';
293
+ c.fillText(fitName(c, name, W - NAME_X - NAME_END), NAME_X, top + NAME_BASE);
107
294
  c.restore();
108
295
  }
109
296
 
110
- /** Far out, a tile is a pin: a small fixed-size card with its icon. */
297
+ /**
298
+ * A note's text over the card, as its editor shows it (Figma's preview: white on Grey - 1, about 9 to the 120 tile): a
299
+ * line every 13.5, and 4.25 more where a new block starts (the editor's half-em paragraph margin; the host marks one
300
+ * with a blank line). Each of the host's lines wraps to the card's width at its spaces. A line the host starts with
301
+ * "• " is a list item, a dot before it, and one it starts with a number ("1. ") a numbered item, the number before it
302
+ * (as the editor numbers them, and the job's own tile): its text further in, and so are its lines after the first. As
303
+ * many lines as fit above the bar.
304
+ */
305
+ const NOTE_PX = 9 * F, NOTE_LINE = 13.5 * F, NOTE_GAP = 4.25 * F, NOTE_X = 8.75 * F, NOTE_FIRST = 17.25 * F;
306
+ const NOTE_DOT_X = 14 * F, NOTE_DOT_R = 1.7 * F, NOTE_ITEM_X = 22.25 * F;
307
+ /** where a number ends: a third of the type's size before its item's text */
308
+ const NOTE_NUM_END = NOTE_ITEM_X - 3 * F;
309
+ function drawNoteText(c: DrawCtx, text: string, foot: number): void {
310
+ c.font = '400 ' + NOTE_PX + 'px ' + TILE_FONT; c.fillStyle = TILE.name; c.textBaseline = 'alphabetic';
311
+ let y = NOTE_FIRST - NOTE_LINE;
312
+ for (const { text: line, x, mark, gap } of noteRows(c, text)) {
313
+ y += NOTE_LINE + (gap ? NOTE_GAP : 0);
314
+ if (y > foot - 4 * F) break;
315
+ if (mark === '•') {
316
+ // (its dot on the line's x-height middle, kept as the badges are)
317
+ dot(c, NOTE_DOT_X, y - NOTE_PX * 0.33, NOTE_DOT_R);
318
+ c.fill();
319
+ } else if (mark) { c.textAlign = 'right'; c.fillText(mark, NOTE_NUM_END, y); }
320
+ c.textAlign = 'left';
321
+ c.fillText(line, x, y);
322
+ }
323
+ }
324
+ /** A dot's path (kept, one a size). */
325
+ function dot(c: DrawCtx, x: number, y: number, r: number): void {
326
+ if (c.shape) c.shape('dot ' + r, x - r, y - r, (cc, X, Y) => { cc.beginPath(); cc.arc(X + r, Y + r, r, 0, Math.PI * 2); });
327
+ else { c.beginPath(); c.arc(x, y, r, 0, Math.PI * 2); }
328
+ }
329
+
330
+ /**
331
+ * A note's text as the host's lines: each with its mark ("•" for a list item, its number for a numbered one) and
332
+ * whether a new block starts there (a blank line before it, left out itself) — at most NOTE_LINES, each no longer than
333
+ * NOTE_CHARS (more than a card shows of one: it's only wrapped as far as that). Split once for each text.
334
+ */
335
+ const NOTE_LINES = 12, NOTE_CHARS = 400;
336
+ const NUMBERED = /^(\d{1,3}\.)\s+/;
337
+ interface NoteLine { text: string; mark?: string; gap: boolean }
338
+ const lineCache = new Map<string, NoteLine[]>();
339
+ function noteLines(text: string): NoteLine[] {
340
+ let lines = lineCache.get(text);
341
+ if (!lines) {
342
+ lines = [];
343
+ let gap = false;
344
+ for (const raw of text.split(/\r?\n/)) {
345
+ const l = raw.trim();
346
+ if (!l) { gap = lines.length > 0; continue; }
347
+ if (lines.length >= NOTE_LINES) break;
348
+ const num = NUMBERED.exec(l), mark = l.startsWith('• ') ? '•' : num?.[1];
349
+ const s = mark === '•' ? l.slice(2).trim() : num ? l.slice(num[0].length) : l;
350
+ lines.push({ text: s.length > NOTE_CHARS ? Array.from(s).slice(0, NOTE_CHARS).join('') : s, mark, gap });
351
+ gap = false;
352
+ }
353
+ if (lineCache.size > 500) lineCache.clear();
354
+ lineCache.set(text, lines);
355
+ }
356
+ return lines;
357
+ }
358
+
359
+ /**
360
+ * The lines a note's card shows: the host's, each wrapped to the card's width (at most NOTE_LINES in all), an item's
361
+ * mark on its first. A note's lines are the same width at every zoom: wrapped once for each text, at each font (one
362
+ * that arrives later wraps it again: watchFonts).
363
+ */
364
+ interface NoteRow { text: string; x: number; mark?: string; gap: boolean }
365
+ const rowCache = new Map<string, NoteRow[]>();
366
+ function noteRows(c: DrawCtx, text: string): NoteRow[] {
367
+ const key = c.font + '|' + text;
368
+ let rows = rowCache.get(key);
369
+ if (rows) return rows;
370
+ rows = [];
371
+ for (const { text: line, mark, gap } of noteLines(text)) {
372
+ const x = mark ? NOTE_ITEM_X : NOTE_X;
373
+ wrap(c, line, L - NOTE_X - x, NOTE_LINES - rows.length).forEach((t, i) => rows!.push({ text: t, x, mark: i ? undefined : mark, gap: !i && gap }));
374
+ if (rows.length >= NOTE_LINES) break;
375
+ }
376
+ if (rowCache.size > 500) rowCache.clear();
377
+ rowCache.set(key, rows);
378
+ return rows;
379
+ }
380
+
381
+ /** `text` as at most `room` lines no wider than `maxW` at the current font, broken at its spaces (a word wider than a line, cut short). */
382
+ function wrap(c: DrawCtx, text: string, maxW: number, room: number): string[] {
383
+ const out: string[] = [];
384
+ let line = '';
385
+ for (const word of text.split(/\s+/)) {
386
+ if (!word || out.length >= room) continue;
387
+ const next = line ? line + ' ' + word : word;
388
+ if (c.measureText(next).width <= maxW) { line = next; continue; }
389
+ if (line) out.push(line);
390
+ line = '';
391
+ if (c.measureText(word).width <= maxW) line = word;
392
+ else if (out.length < room) out.push(fit(c, word, maxW));
393
+ }
394
+ if (line && out.length < room) out.push(line);
395
+ return out;
396
+ }
397
+
398
+ /**
399
+ * A conversion as its screen shows it — Figma's preview is that screen, its top cut to the card (657:8990): the unit
400
+ * it's from on a grey header, what was typed under it (the field's INPUT, with nothing), the swap button, and the unit
401
+ * it's to on a blue header, the result's grey below. `text` is the host's: the two units' names, a line each, then the
402
+ * value typed, if any. Measured at the 120 tile from the frame's screenshot; the colours are BOLDR's palette, as the
403
+ * screen's are (General/Dark Grey, Grey - 1, Grey - 2, Grey - 4; Secondary/Accent Blue - 2 and - 3).
404
+ */
405
+ const CONV = { page: '#181A1C', result: '#2B2C2E', head: '#424345', to: '#4B6074', chipLine: '#2E4C6A', hint: '#9FA0A2' } as const;
406
+ /** its column (the screen's sides, 4.27 in), the headers' tops and height, the field's foot, the corners (8 pt) */
407
+ const CV_X = 4.27 * F, CV_W = 111.46 * F, CV_FROM = 4.48 * F, CV_HEAD = 13.13 * F, CV_FIELD = 66.16 * F;
408
+ const CV_TO = 69.21 * F, CV_RESULT = 82.04 * F, CV_R = 2.44 * F;
409
+ /**
410
+ * the units' names: Semibold 16 pt, 21 pt in from the header's side; the value: Regular 32 pt, its ink 14 pt in from the
411
+ * field's (the screenshot's, both: the screen's code says 24); the swap button: 52 pt, its line 2 pt
412
+ */
413
+ const CV_NAME_PX = 4.885 * F, CV_NAME_X = 10.74 * F, CV_VALUE_PX = 9.77 * F, CV_VALUE_X = 7.55 * F, CV_VALUE_Y = 45.1 * F;
414
+ const CV_CHIP = [52.11 * F, 59.75 * F, 15.88 * F] as const, CV_CHIP_LINE = 0.61 * F;
415
+ /** the chevrons (16 pt, 21 pt in from the header's right, as the screenshot's) and the swap arrows (20 pt), Lucide's, drawn with their 2-in-24 line */
416
+ const CV_CHEV = 4.885 * F, CV_CHEV_X = 104.45 * F, CV_ARROWS = 6.107 * F;
417
+
418
+ function drawConversion(c: DrawCtx, text: string): void {
419
+ const [from = '', to = '', value = ''] = text.split('\n').map(s => s.trim());
420
+ const headMid = (top: number) => top + CV_HEAD / 2;
421
+ // each header, its top rounded, its foot under what's below it: the result's grey, the field
422
+ rr(c, CV_X, CV_TO, CV_W, CV_RESULT - CV_TO + CV_R, CV_R); c.fillStyle = CONV.to; c.fill();
423
+ c.fillStyle = CONV.result; c.fillRect(CV_X, CV_RESULT, CV_W, L - CV_RESULT);
424
+ rr(c, CV_X, CV_FROM, CV_W, CV_HEAD + CV_R, CV_R); c.fillStyle = CONV.head; c.fill();
425
+ // the field: the page's dark, a Grey - 2 line round it (1 pt)
426
+ const fieldTop = CV_FROM + CV_HEAD, line = 0.305 * F;
427
+ c.fillStyle = CONV.page; c.fillRect(CV_X, fieldTop, CV_W, CV_FIELD - fieldTop);
428
+ c.lineWidth = line; c.strokeStyle = CONV.head; c.strokeRect(CV_X + line / 2, fieldTop, CV_W - line, CV_FIELD - fieldTop - line / 2);
429
+ // the units, white, and what was typed (or the field's hint)
430
+ c.textAlign = 'left'; c.textBaseline = 'middle'; c.fillStyle = TILE.name;
431
+ c.font = '600 ' + CV_NAME_PX + 'px ' + TILE_FONT;
432
+ c.fillText(fit(c, from, CV_CHEV_X - CV_NAME_X - 2 * F), CV_NAME_X, headMid(CV_FROM));
433
+ c.fillText(fit(c, to, CV_CHEV_X - CV_NAME_X - 2 * F), CV_NAME_X, headMid(CV_TO));
434
+ c.font = '400 ' + CV_VALUE_PX + 'px ' + TILE_FONT; c.textBaseline = 'alphabetic';
435
+ c.fillStyle = value ? TILE.name : CONV.hint;
436
+ c.fillText(fit(c, value || 'INPUT', CV_X + CV_W - CV_VALUE_X - 2 * F), CV_VALUE_X, CV_VALUE_Y);
437
+ // each header's chevron, and the swap button over where the two meet
438
+ c.lineCap = 'round'; c.lineJoin = 'round'; c.strokeStyle = TILE.name; c.lineWidth = (2 * CV_CHEV) / 24;
439
+ for (const top of [CV_FROM, CV_TO]) { icon(c, 'conv chevron', CHEVRON, CV_CHEV_X, headMid(top) - CV_CHEV / 2, CV_CHEV); c.stroke(); }
440
+ const [cx, cy, cs] = CV_CHIP;
441
+ rr(c, cx, cy, cs, cs, CV_R); c.fillStyle = CONV.chipLine; c.fill();
442
+ rr(c, cx + CV_CHIP_LINE, cy + CV_CHIP_LINE, cs - 2 * CV_CHIP_LINE, cs - 2 * CV_CHIP_LINE, CV_R - CV_CHIP_LINE); c.fillStyle = CONV.to; c.fill();
443
+ c.lineWidth = (2 * CV_ARROWS) / 24;
444
+ icon(c, 'conv arrows', ARROWS, cx + (cs - CV_ARROWS) / 2, cy + (cs - CV_ARROWS) / 2, CV_ARROWS); c.stroke();
445
+ }
446
+
447
+ /** Lucide's chevron-down and arrow-up-down, as polylines on their 24 grid. */
448
+ const CHEVRON: number[][] = [[6, 9, 12, 15, 18, 9]];
449
+ const ARROWS: number[][] = [[21, 16, 17, 20, 13, 16], [17, 20, 17, 4], [3, 8, 7, 4, 11, 8], [7, 4, 7, 20]];
450
+ /** An icon's path (polylines on a 24 grid), `size` across with its top left at x, y: kept, one a size. */
451
+ function icon(c: DrawCtx, key: string, lines: number[][], x: number, y: number, size: number): void {
452
+ const s = size / 24;
453
+ const draw = (cc: DrawCtx, X: number, Y: number) => {
454
+ cc.beginPath();
455
+ for (const p of lines) {
456
+ cc.moveTo(X + p[0] * s, Y + p[1] * s);
457
+ for (let i = 2; i < p.length; i += 2) cc.lineTo(X + p[i] * s, Y + p[i + 1] * s);
458
+ }
459
+ };
460
+ if (c.shape) c.shape(key + ' ' + size, x, y, draw);
461
+ else draw(c, x, y);
462
+ }
463
+
464
+ /** Far out, a tile is a pin: its badge, a fixed size, ringed when it's selected (a picture's: the camera's or sketch's). */
111
465
  function drawPin(c: DrawCtx, b: LinkBox, n: Note, o: LinkDrawOpts): void {
112
- const s = LINK_PIN_PX;
113
- rr(c, b.cx - s / 2, b.cy - s / 2, s, s, 6);
114
- // (selected: ringed round its edge, as a card is)
115
- if (o.selected) { c.lineWidth = 2 * SELECT_W; c.strokeStyle = TH.yellow; c.stroke(); }
116
- c.fillStyle = o.info?.exists === false ? TH.chip : TH.badge; c.fill();
117
- if (!o.selected) { c.lineWidth = 1; c.strokeStyle = TH.badgeline; c.stroke(); }
118
- drawGlyph(c, glyphOf(n, o.info), b.cx, b.cy, 14, TH.ink2);
466
+ const s = LINK_PIN_PX, x = b.cx - s / 2, y = b.cy - s / 2, gone = o.info?.exists === false;
467
+ if (o.selected) {
468
+ rr(c, x, y, s, s, (6 * s) / 20);
469
+ c.lineWidth = 2 * SELECT_W; c.strokeStyle = TH.yellow; c.stroke();
470
+ }
471
+ drawBadge(c, tileTypeOf(n, o.info), x, y, s, gone ? TILE.line : undefined);
119
472
  }
120
473
 
121
- /** A picture filling the rounded rectangle x, y, w, h (its middle shown, its sides cut off). */
122
- function cover(c: DrawCtx, im: ImageLike, x: number, y: number, w: number, h: number, r: number): void {
474
+ /** A picture filling x, y, w, h (its middle shown, its sides cut off: by the card's edge, which the card has clipped to). */
475
+ function cover(c: DrawCtx, im: ImageLike, x: number, y: number, w: number, h: number): void {
123
476
  const iw = im.naturalWidth, ih = im.naturalHeight || 1, k = Math.max(w / iw, h / ih), dw = iw * k, dh = ih * k;
124
- c.save();
125
- rr(c, x, y, w, h, r);
126
- c.clip();
127
477
  c.drawImage(im, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
128
- c.restore();
478
+ }
479
+
480
+ /** A picture whole within x, y, w, h, in its middle. */
481
+ function contain(c: DrawCtx, im: ImageLike, x: number, y: number, w: number, h: number): void {
482
+ const iw = im.naturalWidth, ih = im.naturalHeight || 1, k = Math.min(w / iw, h / ih), dw = iw * k, dh = ih * k;
483
+ c.drawImage(im, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
129
484
  }
130
485
 
131
486
  /*
132
- A name grows with its card until it's NAME_MAX_PX on the screen; past that it stays that size, and the card, wider on
133
- the screen as it's zoomed in, shows more of it — in the end all of it: on two even lines once two show the whole of it
134
- (the strip under a picture always has the room by then), and on one once one does. Each name is measured once, at
135
- each of its two sizes: laying it out again at another width, every step of a pinch, measures nothing.
487
+ A name is one size on the screen, as the bar it's on is; the card, wider on the screen as it's zoomed in, shows more
488
+ of it. Each name is measured once: fitting it again at another width, every step of a pinch, measures nothing.
136
489
  */
137
- const NAME_PX = 11, NAME_MAX_PX = 14, NAME_LINE = 1.25;
138
490
 
139
491
  /** A name's prefixes' widths at a font (w[i]: its first i characters), and an ellipsis's. */
140
492
  const prefixes = new Map<string, { w: number[]; ell: number }>();
@@ -150,83 +502,19 @@ function widthsOf(c: DrawCtx, text: string): { w: number[]; ell: number } {
150
502
  return p;
151
503
  }
152
504
 
505
+ /** Where to cut `text` at `i` without parting a character in two (an emoji's surrogate pair): there, or one before. */
506
+ const cutAt = (text: string, i: number): number => (i > 0 && (text.charCodeAt(i - 1) & 0xfc00) === 0xd800 ? i - 1 : i);
507
+
153
508
  /** The most of `text` that fits `maxW` at the current font: all of it, or its start and an ellipsis. */
154
509
  export function fitName(c: DrawCtx, text: string, maxW: number): string {
155
510
  const { w, ell } = widthsOf(c, text), n = text.length;
156
511
  if (w[n] <= maxW) return text;
157
512
  let lo = 0, hi = n;
158
513
  while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (w[mid] + ell <= maxW) lo = mid; else hi = mid - 1; }
159
- return text.slice(0, lo).trimEnd() + '…';
160
- }
161
-
162
- /**
163
- * Where a name breaks onto two lines: at the space nearest half its width (with none, after the _ - . or / nearest it;
164
- * with none of those, halfway across), so the two are even — and the same however wide its tile, so zooming never
165
- * moves the break. `width`: the wider of the two lines.
166
- */
167
- const splits = new Map<string, { cut: number; next: number; width: number }>();
168
- function splitOf(c: DrawCtx, text: string): { cut: number; next: number; width: number } {
169
- const key = c.font + '|' + text;
170
- let sp = splits.get(key);
171
- if (sp) return sp;
172
- const { w } = widthsOf(c, text), n = text.length, total = w[n];
173
- sp = { cut: 0, next: 0, width: Infinity };
174
- for (let i = 1; i < n - 1; i++) {
175
- if (text[i] !== ' ') continue;
176
- const width = Math.max(w[i], total - w[i + 1]);
177
- if (width < sp.width) sp = { cut: i, next: i + 1, width };
178
- }
179
- // (no space, as a file's name often has none: after a _ - . or /, which stays on the first line)
180
- if (!sp.cut) for (let i = 2; i < n - 1; i++) {
181
- if (!'_-./'.includes(text[i - 1])) continue;
182
- const width = Math.max(w[i], total - w[i]);
183
- if (width < sp.width) sp = { cut: i, next: i, width };
184
- }
185
- if (!sp.cut) {
186
- let i = 1;
187
- while (i < n - 1 && w[i] < total / 2) i++;
188
- sp = { cut: i, next: i, width: Math.max(w[i], total - w[i]) };
189
- }
190
- if (splits.size > 1000) splits.clear();
191
- splits.set(key, sp);
192
- return sp;
193
- }
194
-
195
- /** All of `text` on two lines `maxW` wide, evenly (see splitOf); null if one line takes it, or two can't. */
196
- export function twoLines(c: DrawCtx, text: string, maxW: number): [string, string] | null {
197
- const n = text.length;
198
- if (n < 2 || widthsOf(c, text).w[n] <= maxW) return null;
199
- const sp = splitOf(c, text);
200
- return sp.width <= maxW ? [text.slice(0, sp.cut).trimEnd(), text.slice(sp.next).trimStart()] : null;
201
- }
202
-
203
- /**
204
- * A name on card `k` (its scale from the layout), centred on `y` and at most `maxW` wide in the layout: the layout's
205
- * size until that's NAME_MAX_PX on the screen; past it, that size on the screen (drawn at the screen's own scale, so one
206
- * font does), showing more of the name as the card grows — and with `wrap`, on two lines once two show all of it.
207
- */
208
- function drawName(c: DrawCtx, k: number, text: string, y: number, maxW: number, color: string, wrap: boolean): void {
209
- c.fillStyle = color; c.textAlign = 'center'; c.textBaseline = 'middle';
210
- if (NAME_PX * k <= NAME_MAX_PX) {
211
- c.font = '600 ' + NAME_PX + 'px ' + FONT;
212
- c.fillText(fitName(c, text, maxW), L / 2, y);
213
- return;
214
- }
215
- c.save();
216
- c.scale(1 / k, 1 / k);
217
- c.font = '600 ' + NAME_MAX_PX + 'px ' + FONT;
218
- const W = maxW * k, x = (L / 2) * k, one = fitName(c, text, W);
219
- const two = wrap && one !== text ? twoLines(c, text, W) : null;
220
- if (two) {
221
- // (centred in the strip under the picture: from 68 to the card's foot)
222
- const h = NAME_MAX_PX * NAME_LINE, mid = 82 * k;
223
- c.fillText(two[0], x, mid - h / 2);
224
- c.fillText(two[1], x, mid + h / 2);
225
- } else c.fillText(one, x, y * k);
226
- c.restore();
514
+ return text.slice(0, cutAt(text, lo)).trimEnd() + '…';
227
515
  }
228
516
 
229
- /** Names cut to fit, by font, width and text: measured once, not every frame. */
517
+ /** Text cut to fit, by font, width and text: measured once, not every frame. */
230
518
  const fitted = new Map<string, string>();
231
519
  function fit(c: DrawCtx, text: string, maxW: number): string {
232
520
  const key = c.font + '|' + maxW + '|' + text;
@@ -236,62 +524,20 @@ function fit(c: DrawCtx, text: string, maxW: number): string {
236
524
  if (c.measureText(text).width > maxW) {
237
525
  let lo = 0, hi = text.length;
238
526
  while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (c.measureText(text.slice(0, mid) + '…').width <= maxW) lo = mid; else hi = mid - 1; }
239
- out = text.slice(0, lo).trimEnd() + '…';
527
+ out = text.slice(0, cutAt(text, lo)).trimEnd() + '…';
240
528
  }
241
529
  if (fitted.size > 1000) fitted.clear();
242
530
  fitted.set(key, out);
243
531
  return out;
244
532
  }
245
533
 
246
- /** A glyph's parts on a 24-unit square: what's stroked (dashed, then solid), what's filled, and a word. */
247
- interface GlyphDraw { dashed?: (c: DrawCtx) => void; stroke?: (c: DrawCtx) => void; fill?: (c: DrawCtx) => void; text?: string }
248
-
249
- const page = (c: DrawCtx, fold: boolean) => {
250
- c.moveTo(6, 3); c.lineTo(fold ? 14 : 18, 3);
251
- if (fold) c.lineTo(18, 7);
252
- c.lineTo(18, 21); c.lineTo(6, 21); c.closePath();
253
- if (fold) { c.moveTo(14, 3); c.lineTo(14, 7); c.lineTo(18, 7); }
254
- };
255
- const rules = (c: DrawCtx, ys: number[]) => { for (const y of ys) { c.moveTo(9, y); c.lineTo(15, y); } };
256
- const dots = (c: DrawCtx, at: [number, number][], r: number) => { for (const [x, y] of at) { c.moveTo(x + r, y); c.arc(x, y, r, 0, Math.PI * 2); } };
257
-
258
- const GLYPHS: Record<Glyph, GlyphDraw> = {
259
- photo: {
260
- stroke: c => { c.roundRect(3, 5, 18, 14, 2); c.moveTo(5, 17); c.lineTo(10, 11); c.lineTo(13, 14); c.lineTo(15, 12); c.lineTo(19, 17); },
261
- fill: c => dots(c, [[16, 9]], 1.6),
262
- },
263
- pdf: { stroke: c => page(c, true), text: 'PDF' },
264
- note: { stroke: c => { page(c, false); rules(c, [8, 12, 16]); } },
265
- calculator: {
266
- stroke: c => { c.roundRect(5, 3, 14, 18, 2); c.moveTo(8, 7); c.lineTo(16, 7); },
267
- fill: c => dots(c, [11.5, 15, 18.5].flatMap(y => [8.5, 12, 15.5].map(x => [x, y] as [number, number])), 0.9),
268
- },
269
- label: {
270
- stroke: c => { c.moveTo(4, 8); c.lineTo(14, 8); c.lineTo(20, 12); c.lineTo(14, 16); c.lineTo(4, 16); c.closePath(); },
271
- fill: c => dots(c, [[14, 12]], 1.2),
272
- },
273
- drawing: { stroke: c => { page(c, false); c.moveTo(9, 17); c.lineTo(15.5, 8.5); } },
274
- tape: { stroke: c => { c.roundRect(2.5, 8, 19, 8, 1.5); for (const x of [6, 9, 12, 15, 18]) { c.moveTo(x, 8); c.lineTo(x, x === 12 ? 12.5 : 11); } } },
275
- removed: { dashed: c => page(c, false), stroke: c => { c.moveTo(4, 20); c.lineTo(20, 4); } },
276
- document: { stroke: c => { page(c, true); rules(c, [12, 16]); } },
277
- };
278
-
279
- /** A glyph part's path: kept as a shape where the engine can (a tile's icon is drawn every frame, the same each time). */
280
- function part(c: DrawCtx, key: string, draw: (c: DrawCtx) => void): void {
281
- if (c.shape) c.shape(key, 0, 0, cc => draw(cc));
282
- else { c.beginPath(); draw(c); }
283
- }
284
-
285
- /** An icon for kind `g`, `size` across, centred on cx, cy (paths: the canvas can't draw the chrome's SVG icons). */
286
- function drawGlyph(c: DrawCtx, g: Glyph, cx: number, cy: number, size: number, color: string): void {
287
- const u = size / 24, d = GLYPHS[g];
288
- c.save();
289
- c.translate(cx - size / 2, cy - size / 2);
290
- c.scale(u, u);
291
- c.strokeStyle = color; c.fillStyle = color; c.lineWidth = 1.8; c.lineCap = 'round'; c.lineJoin = 'round';
292
- if (d.dashed) { c.setLineDash([2.2, 2]); part(c, 'glyph- ' + g, d.dashed); c.stroke(); c.setLineDash([]); }
293
- if (d.stroke) { part(c, 'glyph| ' + g, d.stroke); c.stroke(); }
294
- if (d.fill) { part(c, 'glyph. ' + g, d.fill); c.fill(); }
295
- if (d.text) { c.font = '700 6px ' + FONT; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(d.text, 12, 15); }
296
- c.restore();
534
+ /**
535
+ * In a browser, a font can arrive after a tile's text was measured in another (Inter, still loading, the first time a
536
+ * page draws): measured again once it has. (A device draws in the fonts it's given.)
537
+ */
538
+ let watchingFonts = false;
539
+ function watchFonts(): void {
540
+ watchingFonts = true;
541
+ const fonts = typeof document !== 'undefined' ? document.fonts : undefined;
542
+ fonts?.addEventListener?.('loadingdone', () => { prefixes.clear(); fitted.clear(); rowCache.clear(); requestDraw(); });
297
543
  }