@reekon-tools/boldr-utils 1.7.5 → 1.8.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 (72) hide show
  1. package/dist/annotation/canvas/elements/BackgroundImageElement.js +15 -3
  2. package/dist/annotation/canvas/tools/measurementLineTool.d.ts +12 -0
  3. package/dist/annotation/canvas/tools/measurementLineTool.js +95 -0
  4. package/dist/calculator/categories.d.ts +76 -0
  5. package/dist/calculator/categories.js +160 -0
  6. package/dist/calculator/index.d.ts +1 -0
  7. package/dist/calculator/index.js +1 -0
  8. package/dist/calculator/schema.d.ts +12 -0
  9. package/dist/calculator/schema.js +3 -0
  10. package/dist/calculator/validate.d.ts +1 -0
  11. package/dist/calculator/validate.js +15 -0
  12. package/package.json +2 -1
  13. package/dist/canvas/AnnotationCanvas.d.ts +0 -11
  14. package/dist/canvas/AnnotationCanvas.js +0 -10
  15. package/dist/canvas/AnnotationCanvas.native.d.ts +0 -8
  16. package/dist/canvas/AnnotationCanvas.native.js +0 -6
  17. package/dist/canvas/AnnotationCanvasInner.d.ts +0 -39
  18. package/dist/canvas/AnnotationCanvasInner.js +0 -219
  19. package/dist/canvas/AnnotationCanvasInner.native.d.ts +0 -35
  20. package/dist/canvas/AnnotationCanvasInner.native.js +0 -138
  21. package/dist/canvas/AnnotationCanvasSkia.d.ts +0 -27
  22. package/dist/canvas/AnnotationCanvasSkia.js +0 -20
  23. package/dist/canvas/Tool.d.ts +0 -38
  24. package/dist/canvas/Tool.js +0 -1
  25. package/dist/canvas/elements/BackgroundImageElement.d.ts +0 -9
  26. package/dist/canvas/elements/BackgroundImageElement.js +0 -37
  27. package/dist/canvas/elements/MeasurementStampElement.d.ts +0 -13
  28. package/dist/canvas/elements/MeasurementStampElement.js +0 -30
  29. package/dist/canvas/elements/ShapeElement.d.ts +0 -7
  30. package/dist/canvas/elements/ShapeElement.js +0 -62
  31. package/dist/canvas/elements/StrokeElement.d.ts +0 -7
  32. package/dist/canvas/elements/StrokeElement.js +0 -18
  33. package/dist/canvas/measurementPicker.d.ts +0 -10
  34. package/dist/canvas/measurementPicker.js +0 -1
  35. package/dist/canvas/measurementStampOverlay.d.ts +0 -11
  36. package/dist/canvas/measurementStampOverlay.js +0 -1
  37. package/dist/canvas/pointerAdapter.d.ts +0 -3
  38. package/dist/canvas/pointerAdapter.js +0 -19
  39. package/dist/canvas/stampLayout.d.ts +0 -5
  40. package/dist/canvas/stampLayout.js +0 -14
  41. package/dist/canvas/tools/measurementStampTool.d.ts +0 -9
  42. package/dist/canvas/tools/measurementStampTool.js +0 -37
  43. package/dist/canvas/tools/panTool.d.ts +0 -5
  44. package/dist/canvas/tools/panTool.js +0 -25
  45. package/dist/canvas/tools/penTool.d.ts +0 -13
  46. package/dist/canvas/tools/penTool.js +0 -68
  47. package/dist/canvas/tools/selectTool.d.ts +0 -2
  48. package/dist/canvas/tools/selectTool.js +0 -182
  49. package/dist/canvas/useAnnotationCanvasState.d.ts +0 -54
  50. package/dist/canvas/useAnnotationCanvasState.js +0 -210
  51. package/dist/canvas/viewport.d.ts +0 -16
  52. package/dist/canvas/viewport.js +0 -54
  53. package/dist/data/AnnotationDataContext.d.ts +0 -8
  54. package/dist/data/AnnotationDataContext.js +0 -11
  55. package/dist/data/AnnotationDataProvider.d.ts +0 -65
  56. package/dist/data/AnnotationDataProvider.js +0 -4
  57. package/dist/data/InMemoryAnnotationProvider.d.ts +0 -30
  58. package/dist/data/InMemoryAnnotationProvider.js +0 -197
  59. package/dist/data/canvasPersistence.d.ts +0 -3
  60. package/dist/data/canvasPersistence.js +0 -26
  61. package/dist/data/hooks/useAnnotationCanvasDoc.d.ts +0 -33
  62. package/dist/data/hooks/useAnnotationCanvasDoc.js +0 -314
  63. package/dist/data/hooks/useAnnotationDoc.d.ts +0 -7
  64. package/dist/data/hooks/useAnnotationDoc.js +0 -33
  65. package/dist/data/hooks/useAnnotationList.d.ts +0 -7
  66. package/dist/data/hooks/useAnnotationList.js +0 -26
  67. package/dist/data/hooks/useAnnotationMutations.d.ts +0 -9
  68. package/dist/data/hooks/useAnnotationMutations.js +0 -11
  69. package/dist/hooks/useParseMeasurement.d.ts +0 -4
  70. package/dist/hooks/useParseMeasurement.js +0 -14
  71. package/dist/utils/evaluateFormula.d.ts +0 -20
  72. package/dist/utils/evaluateFormula.js +0 -31
@@ -42,6 +42,11 @@ const useBackgroundUrl = (image, resolveUrl) => {
42
42
  // on a null/undefined source (despite its DataSourceParam type), so it can't
43
43
  // be called unconditionally from a component that usually renders raster
44
44
  // backgrounds. An empty url resolves to null (nothing to draw yet).
45
+ // Loaded through Skia's own scheme-agnostic data loader rather than fetch():
46
+ // RN's Android networking rejects non-http(s) schemes, and offline-first
47
+ // consumers resolve the background to a local file:// URI (fromURI handles
48
+ // file://, http(s) and data: on native and web alike). fetch() remains as a
49
+ // fallback for any environment where fromURI can't service the URL.
45
50
  const useBackgroundSvg = (url) => {
46
51
  const [svg, setSvg] = useState(null);
47
52
  useEffect(() => {
@@ -52,9 +57,16 @@ const useBackgroundSvg = (url) => {
52
57
  let cancelled = false;
53
58
  (async () => {
54
59
  try {
55
- const res = await fetch(url);
56
- const text = await res.text();
57
- const next = Skia.SVG.MakeFromString(text);
60
+ let next = null;
61
+ try {
62
+ const data = await Skia.Data.fromURI(url);
63
+ next = Skia.SVG.MakeFromData(data);
64
+ data.dispose();
65
+ }
66
+ catch {
67
+ const res = await fetch(url);
68
+ next = Skia.SVG.MakeFromString(await res.text());
69
+ }
58
70
  if (!cancelled)
59
71
  setSvg(next);
60
72
  }
@@ -0,0 +1,12 @@
1
+ import type { PlacedMeasurementRef } from '../../../types/annotation.js';
2
+ import type { Tool } from '../Tool.js';
3
+ export interface MeasurementLineToolOptions {
4
+ id?: string;
5
+ label?: string;
6
+ minDragPx?: number;
7
+ autoSwitchToSelect?: boolean;
8
+ selectToolId?: string;
9
+ onAutoSwitch?: (toToolId: string) => void;
10
+ onPlaced?: (measurement: PlacedMeasurementRef) => void;
11
+ }
12
+ export declare const createMeasurementLineTool: (options?: MeasurementLineToolOptions) => Tool;
@@ -0,0 +1,95 @@
1
+ import { DEFAULT_LAYER_ID } from '../../../types/annotation.js';
2
+ import { DEFAULT_LINE_POS, recomputeAnchor } from '../measurementGeometry.js';
3
+ let counter = 0;
4
+ const makeId = () => `annotation-${Date.now().toString(36)}-${(counter++).toString(36)}`;
5
+ const firstLayerId = (doc) => doc.layers[0]?.id ?? DEFAULT_LAYER_ID;
6
+ // Build the blank line-measurement annotation for a drag from `a` to `b`: a
7
+ // 2-point line with a value tile magnetized to its center (linePos 0.5). The
8
+ // payload mirrors useAnnotationCanvasState's placeAnnotationAtCenter so a drawn
9
+ // line and a (legacy) center-placed one are byte-identical once committed.
10
+ const buildLineMeasurement = (opts) => {
11
+ const line = { a: opts.a, b: opts.b };
12
+ return {
13
+ id: opts.id,
14
+ layerId: opts.layerId,
15
+ placement: 'line',
16
+ line,
17
+ linePos: DEFAULT_LINE_POS,
18
+ // Center of the line; recomputeAnchor keeps this in sync on edits.
19
+ anchor: recomputeAnchor(line, 'line', DEFAULT_LINE_POS, {
20
+ x: (opts.a.x + opts.b.x) / 2,
21
+ y: (opts.a.y + opts.b.y) / 2,
22
+ }),
23
+ showLabel: true,
24
+ showValue: true,
25
+ createdAt: Date.now(),
26
+ };
27
+ };
28
+ // Drag-to-draw measurement-line tool. Press-drag rubber-bands a measurement
29
+ // annotation (a line with a blank value tile at its center); release commits
30
+ // it. Mirrors createShapeTool's interaction so "input lines" are placed the
31
+ // same way as shapes — draw to add, not tap-the-icon-to-add — replacing the
32
+ // old center-place affordance. The annotation stays blank (no measurement
33
+ // associated) until the user fills it via the tile / picker. Skia-free, like
34
+ // every tool factory; it renders its live preview through ctx.preview, so it
35
+ // works on web and (via the generic tool-pan dispatch) on native.
36
+ export const createMeasurementLineTool = (options = {}) => {
37
+ const minDragPx = options.minDragPx ?? 4;
38
+ const autoSwitchToSelect = options.autoSwitchToSelect ?? true;
39
+ const selectToolId = options.selectToolId ?? 'select';
40
+ return {
41
+ id: options.id ?? 'measure-line',
42
+ label: options.label ?? 'Measurement line',
43
+ cursor: 'crosshair',
44
+ onPointerDown(event) {
45
+ return {
46
+ kind: 'measurement-line-drawing',
47
+ id: makeId(),
48
+ startWorld: event.world,
49
+ startScreen: event.screen,
50
+ moved: false,
51
+ };
52
+ },
53
+ onPointerMove(event, ctx, state) {
54
+ const s = state;
55
+ if (s?.kind !== 'measurement-line-drawing')
56
+ return s;
57
+ const measurement = buildLineMeasurement({
58
+ id: s.id,
59
+ layerId: firstLayerId(ctx.document),
60
+ a: s.startWorld,
61
+ b: event.world,
62
+ });
63
+ ctx.preview({ ops: [{ op: 'addMeasurement', measurement }] });
64
+ return { ...s, moved: true };
65
+ },
66
+ onPointerUp(event, ctx, state) {
67
+ const s = state;
68
+ if (s?.kind !== 'measurement-line-drawing')
69
+ return;
70
+ const dx = event.screen.x - s.startScreen.x;
71
+ const dy = event.screen.y - s.startScreen.y;
72
+ if (!s.moved || dx * dx + dy * dy < minDragPx * minDragPx) {
73
+ // Accidental tap — discard the rubber-band, commit nothing.
74
+ ctx.preview({ ops: [] });
75
+ return;
76
+ }
77
+ const measurement = buildLineMeasurement({
78
+ id: s.id,
79
+ layerId: firstLayerId(ctx.document),
80
+ a: s.startWorld,
81
+ b: event.world,
82
+ });
83
+ ctx.commit({ ops: [{ op: 'addMeasurement', measurement }] });
84
+ // Leave it selected (and hand back to select) so the blank line can be
85
+ // filled / moved straight away — the same flow as the text tool.
86
+ ctx.setSelection({ ids: [measurement.id] });
87
+ options.onPlaced?.(measurement);
88
+ if (autoSwitchToSelect)
89
+ options.onAutoSwitch?.(selectToolId);
90
+ },
91
+ onCancel(_state, ctx) {
92
+ ctx.preview({ ops: [] });
93
+ },
94
+ };
95
+ };
@@ -0,0 +1,76 @@
1
+ import type { CalculatorDefinition } from './schema.js';
2
+ /**
3
+ * Category ids, as PERSISTED on `calculators/{id}.category`.
4
+ *
5
+ * Never rename a value — it is stored data. To retire a category, delete its
6
+ * entry from CALCULATOR_CATEGORIES and leave existing docs alone: they resolve
7
+ * to `undefined` and degrade into the "Other" bucket rather than breaking.
8
+ */
9
+ export declare enum CalculatorCategoryId {
10
+ AreaAndVolume = "areaAndVolume",
11
+ Concrete = "concrete",
12
+ Electrical = "electrical",
13
+ Stairs = "stairs",
14
+ Framing = "framing",
15
+ FinishCarpentry = "finishCarpentry",
16
+ Hardscaping = "hardscaping",
17
+ Roofing = "roofing",
18
+ HVAC = "hvac",
19
+ Landscaping = "landscaping",
20
+ MaterialEstimator = "materialEstimator",
21
+ Optimizers = "optimizers",
22
+ Plumbing = "plumbing"
23
+ }
24
+ export interface CalculatorCategory {
25
+ id: CalculatorCategoryId;
26
+ /** Display label: the editor dropdown row AND the mobile category screen title. */
27
+ name: string;
28
+ /**
29
+ * Tile-glyph tint from the design. Uppercase 6-digit hex.
30
+ *
31
+ * This is the source of truth for the color: the icon generator bakes it
32
+ * into the per-app SVGs (see scripts/gen-category-icons.mjs), so a color
33
+ * change here needs a regenerate to reach the apps.
34
+ */
35
+ color: string;
36
+ /**
37
+ * Glyph name in the legacy ROCK app's IcoMoon set, which is where this
38
+ * artwork comes from. Consumed only by the icon generator — clients load the
39
+ * emitted SVG by category id.
40
+ */
41
+ glyph: string;
42
+ }
43
+ /**
44
+ * Canonical order, matching the design's tile grid.
45
+ *
46
+ * This array IS the ordering — there is deliberately no `sortIndex` field for
47
+ * it to drift out of sync with.
48
+ */
49
+ export declare const CALCULATOR_CATEGORIES: readonly CalculatorCategory[];
50
+ export declare const CALCULATOR_CATEGORY_BY_ID: Readonly<Record<CalculatorCategoryId, CalculatorCategory>>;
51
+ /**
52
+ * Bucket key / route segment for calculators with no category, or with one
53
+ * that no longer exists. NOT a CalculatorCategoryId — guarded by a unit test,
54
+ * because a collision would silently swallow a real category.
55
+ */
56
+ export declare const UNCATEGORIZED_CATEGORY_ID = "uncategorized";
57
+ export declare const UNCATEGORIZED_CATEGORY_NAME = "Other";
58
+ export type CalculatorCategoryBucketId = CalculatorCategoryId | typeof UNCATEGORIZED_CATEGORY_ID;
59
+ export declare const isCalculatorCategoryId: (value: unknown) => value is CalculatorCategoryId;
60
+ /**
61
+ * Resolve a stored category value. Returns undefined for missing, null, and
62
+ * retired ids — callers should treat all three as "Other" rather than trusting
63
+ * the raw field.
64
+ */
65
+ export declare const findCalculatorCategory: (value: string | null | undefined) => CalculatorCategory | undefined;
66
+ export declare const calculatorCategoryOf: (calculator: Pick<CalculatorDefinition, "category">) => CalculatorCategory | undefined;
67
+ /** Display label for a stored value, falling back to "Other". */
68
+ export declare const calculatorCategoryName: (value: string | null | undefined) => string;
69
+ /**
70
+ * Group calculators by category id, collecting anything missing or
71
+ * unresolvable under UNCATEGORIZED_CATEGORY_ID.
72
+ *
73
+ * Keys exist only for non-empty buckets, which is what lets the mobile grid
74
+ * hide empty categories without a second pass.
75
+ */
76
+ export declare const groupCalculatorsByCategory: <T extends Pick<CalculatorDefinition, "category">>(calculators: readonly T[]) => Map<CalculatorCategoryBucketId, T[]>;
@@ -0,0 +1,160 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Calculator categories — the hard-coded taxonomy used to group published
3
+ // calculators in the mobile catalog and to label them in the web authoring
4
+ // tool.
5
+ //
6
+ // Deliberately NOT a Firestore collection: the set is authored here and ships
7
+ // inside the package, so both clients render the same list with no extra read,
8
+ // no rules surface, and no offline story to maintain.
9
+ //
10
+ // Distinct from `CalculatorDefinition.folderId`, which predates this and is
11
+ // still unused — `folderId` implies a hierarchy, a category is flat.
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * Category ids, as PERSISTED on `calculators/{id}.category`.
15
+ *
16
+ * Never rename a value — it is stored data. To retire a category, delete its
17
+ * entry from CALCULATOR_CATEGORIES and leave existing docs alone: they resolve
18
+ * to `undefined` and degrade into the "Other" bucket rather than breaking.
19
+ */
20
+ export var CalculatorCategoryId;
21
+ (function (CalculatorCategoryId) {
22
+ CalculatorCategoryId["AreaAndVolume"] = "areaAndVolume";
23
+ CalculatorCategoryId["Concrete"] = "concrete";
24
+ CalculatorCategoryId["Electrical"] = "electrical";
25
+ CalculatorCategoryId["Stairs"] = "stairs";
26
+ CalculatorCategoryId["Framing"] = "framing";
27
+ CalculatorCategoryId["FinishCarpentry"] = "finishCarpentry";
28
+ CalculatorCategoryId["Hardscaping"] = "hardscaping";
29
+ CalculatorCategoryId["Roofing"] = "roofing";
30
+ CalculatorCategoryId["HVAC"] = "hvac";
31
+ CalculatorCategoryId["Landscaping"] = "landscaping";
32
+ CalculatorCategoryId["MaterialEstimator"] = "materialEstimator";
33
+ CalculatorCategoryId["Optimizers"] = "optimizers";
34
+ CalculatorCategoryId["Plumbing"] = "plumbing";
35
+ })(CalculatorCategoryId || (CalculatorCategoryId = {}));
36
+ /**
37
+ * Canonical order, matching the design's tile grid.
38
+ *
39
+ * This array IS the ordering — there is deliberately no `sortIndex` field for
40
+ * it to drift out of sync with.
41
+ */
42
+ export const CALCULATOR_CATEGORIES = [
43
+ {
44
+ id: CalculatorCategoryId.AreaAndVolume,
45
+ name: 'Area and Volume',
46
+ color: '#FF3A6E',
47
+ glyph: 'shape_calculator',
48
+ },
49
+ {
50
+ id: CalculatorCategoryId.Concrete,
51
+ name: 'Concrete',
52
+ color: '#E2AB6B',
53
+ glyph: 'concrete',
54
+ },
55
+ {
56
+ id: CalculatorCategoryId.Electrical,
57
+ name: 'Electrical',
58
+ color: '#60D8FE',
59
+ glyph: 'electrical',
60
+ },
61
+ {
62
+ id: CalculatorCategoryId.Stairs,
63
+ name: 'Stairs',
64
+ color: '#D09C9C',
65
+ glyph: 'stair_tread',
66
+ },
67
+ {
68
+ id: CalculatorCategoryId.Framing,
69
+ name: 'Framing',
70
+ color: '#FABC61',
71
+ glyph: 'frame',
72
+ },
73
+ {
74
+ id: CalculatorCategoryId.FinishCarpentry,
75
+ name: 'Finish Carpentry',
76
+ color: '#2E9E82',
77
+ glyph: 'finish_carpentry',
78
+ },
79
+ {
80
+ id: CalculatorCategoryId.Hardscaping,
81
+ name: 'Hardscaping',
82
+ color: '#6B77E2',
83
+ glyph: 'hardscaping',
84
+ },
85
+ {
86
+ id: CalculatorCategoryId.Roofing,
87
+ name: 'Roofing',
88
+ color: '#E48787',
89
+ glyph: 'roofing',
90
+ },
91
+ {
92
+ id: CalculatorCategoryId.HVAC,
93
+ name: 'HVAC',
94
+ color: '#FEF260',
95
+ glyph: 'hvac',
96
+ },
97
+ {
98
+ id: CalculatorCategoryId.Landscaping,
99
+ name: 'Landscaping',
100
+ color: '#C892FF',
101
+ glyph: 'landscaping',
102
+ },
103
+ {
104
+ id: CalculatorCategoryId.MaterialEstimator,
105
+ name: 'Material Estimator',
106
+ color: '#FFAD00',
107
+ glyph: 'raw_materials',
108
+ },
109
+ {
110
+ id: CalculatorCategoryId.Optimizers,
111
+ name: 'Optimizers',
112
+ color: '#CA5726',
113
+ glyph: 'wall',
114
+ },
115
+ {
116
+ id: CalculatorCategoryId.Plumbing,
117
+ name: 'Plumbing',
118
+ color: '#008EFD',
119
+ glyph: 'plumbing',
120
+ },
121
+ ];
122
+ export const CALCULATOR_CATEGORY_BY_ID = Object.fromEntries(CALCULATOR_CATEGORIES.map((category) => [category.id, category]));
123
+ /**
124
+ * Bucket key / route segment for calculators with no category, or with one
125
+ * that no longer exists. NOT a CalculatorCategoryId — guarded by a unit test,
126
+ * because a collision would silently swallow a real category.
127
+ */
128
+ export const UNCATEGORIZED_CATEGORY_ID = 'uncategorized';
129
+ export const UNCATEGORIZED_CATEGORY_NAME = 'Other';
130
+ export const isCalculatorCategoryId = (value) => typeof value === 'string' && value in CALCULATOR_CATEGORY_BY_ID;
131
+ /**
132
+ * Resolve a stored category value. Returns undefined for missing, null, and
133
+ * retired ids — callers should treat all three as "Other" rather than trusting
134
+ * the raw field.
135
+ */
136
+ export const findCalculatorCategory = (value) => isCalculatorCategoryId(value) ? CALCULATOR_CATEGORY_BY_ID[value] : undefined;
137
+ export const calculatorCategoryOf = (calculator) => findCalculatorCategory(calculator.category);
138
+ /** Display label for a stored value, falling back to "Other". */
139
+ export const calculatorCategoryName = (value) => findCalculatorCategory(value)?.name ?? UNCATEGORIZED_CATEGORY_NAME;
140
+ /**
141
+ * Group calculators by category id, collecting anything missing or
142
+ * unresolvable under UNCATEGORIZED_CATEGORY_ID.
143
+ *
144
+ * Keys exist only for non-empty buckets, which is what lets the mobile grid
145
+ * hide empty categories without a second pass.
146
+ */
147
+ export const groupCalculatorsByCategory = (calculators) => {
148
+ const buckets = new Map();
149
+ for (const calculator of calculators) {
150
+ const key = isCalculatorCategoryId(calculator.category)
151
+ ? calculator.category
152
+ : UNCATEGORIZED_CATEGORY_ID;
153
+ const bucket = buckets.get(key);
154
+ if (bucket)
155
+ bucket.push(calculator);
156
+ else
157
+ buckets.set(key, [calculator]);
158
+ }
159
+ return buckets;
160
+ };
@@ -1,4 +1,5 @@
1
1
  export * from './schema.js';
2
+ export * from './categories.js';
2
3
  export * from './units.js';
3
4
  export * from './evaluate.js';
4
5
  export * from './solve.js';
@@ -3,6 +3,7 @@
3
3
  // definition validation. Pure logic (mathjs + zod only — no Skia/React), safe
4
4
  // on web, native, and Node.
5
5
  export * from './schema.js';
6
+ export * from './categories.js';
6
7
  export * from './units.js';
7
8
  export * from './evaluate.js';
8
9
  export * from './solve.js';
@@ -1,5 +1,6 @@
1
1
  import { ColumnType, type ColumnConfig, type ConversionTableColumnData, type InstructionsColumnData, type SelectColumnData, type DecimalTolerance, type FractionalTolerance } from '../types/firestore.js';
2
2
  import type { AngleUnit, CalculatorUnit, FieldDimension, MeasurementDimension } from './units.js';
3
+ import type { CalculatorCategoryId } from './categories.js';
3
4
  /**
4
5
  * Version of the definition format itself. Bumped only on breaking schema
5
6
  * changes; independent of the npm package version and of each calculator's
@@ -123,6 +124,17 @@ export interface CalculatorDefinition {
123
124
  name: string;
124
125
  description: string;
125
126
  folderId: string | null;
127
+ /**
128
+ * Hard-coded category (see categories.ts) driving the mobile category grid
129
+ * and the web library badge.
130
+ *
131
+ * AUTHORING METADATA ONLY — never needed to run a calculator, so it stays
132
+ * optional and nullable: docs authored before categories existed have no
133
+ * value and must keep validating. Always resolve through
134
+ * findCalculatorCategory() rather than comparing the raw field, so a retired
135
+ * id degrades to "Other" instead of vanishing.
136
+ */
137
+ category?: CalculatorCategoryId | null;
126
138
  /** Inputs + outputs in one list, discriminated by `role`. */
127
139
  fields: CalculatorField[];
128
140
  equations: CalculatorEquation[];
@@ -122,6 +122,9 @@ export const createEmptyCalculatorDefinition = (id) => ({
122
122
  name: '',
123
123
  description: '',
124
124
  folderId: null,
125
+ // `null`, never undefined — this object is spread straight into setDoc and
126
+ // Firestore rejects undefined field values.
127
+ category: null,
125
128
  fields: [],
126
129
  equations: [],
127
130
  diagramFileId: null,
@@ -7,6 +7,7 @@ export declare const calculatorDefinitionSchema: z.ZodObject<{
7
7
  name: z.ZodString;
8
8
  description: z.ZodString;
9
9
  folderId: z.ZodNullable<z.ZodString>;
10
+ category: z.ZodOptional<z.ZodNullable<z.ZodString>>;
10
11
  fields: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
11
12
  kind: z.ZodLiteral<ColumnType.Number>;
12
13
  defaultValue: z.ZodOptional<z.ZodNumber>;
@@ -3,6 +3,7 @@ import { create, all } from 'mathjs';
3
3
  import { ColumnType, DecimalTolerance, FractionalTolerance, } from '../types/firestore.js';
4
4
  import { CALCULATOR_SCHEMA_VERSION, equationForField, isEquationTargetKind, isNumericFieldKind, } from './schema.js';
5
5
  import { CALCULATOR_UNIT_INFO, unitDimension } from './units.js';
6
+ import { isCalculatorCategoryId } from './categories.js';
6
7
  const math = create(all);
7
8
  // ---------------------------------------------------------------------------
8
9
  // Definition validation: zod for structure, plus the cross-field invariants
@@ -101,6 +102,14 @@ export const calculatorDefinitionSchema = z.object({
101
102
  name: z.string(),
102
103
  description: z.string(),
103
104
  folderId: z.string().nullable(),
105
+ // Deliberately z.string(), NOT z.enum(CalculatorCategoryId): a safeParse
106
+ // failure returns early below, so a retired or typo'd id would suppress
107
+ // every other invariant and mark the whole calculator broken. Unknown ids
108
+ // are reported further down as an advisory warning instead.
109
+ //
110
+ // The key must be listed even though it's optional — zod strips unknown
111
+ // keys, so omitting it here would make the check below never fire.
112
+ category: z.string().nullish(),
104
113
  fields: z.array(calculatorFieldSchema),
105
114
  equations: z.array(calculatorEquationSchema),
106
115
  diagramFileId: z.string().nullable().optional(),
@@ -187,6 +196,12 @@ export const validateCalculatorDefinition = (input) => {
187
196
  if (def.schemaVersion > CALCULATOR_SCHEMA_VERSION) {
188
197
  push('schema-version-unsupported', 'schemaVersion', `schemaVersion ${def.schemaVersion} is newer than this client supports (${CALCULATOR_SCHEMA_VERSION})`);
189
198
  }
199
+ // A category the client doesn't know is a labelling problem, not a broken
200
+ // calculator — an older app build reading a newly-added category must still
201
+ // run it. Warning severity keeps `ok` true.
202
+ if (def.category != null && !isCalculatorCategoryId(def.category)) {
203
+ push('unknown-category', 'category', `Category "${def.category}" is not a known calculator category`, 'warning');
204
+ }
190
205
  // --- Unique ids ---------------------------------------------------------
191
206
  const fieldIds = new Set();
192
207
  def.fields.forEach((field, i) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.7.5",
3
+ "version": "1.8.0",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "build": "tsc",
26
26
  "prepack": "yarn run build",
27
27
  "sync:local": "./scripts/sync-local.sh",
28
+ "gen:category-icons": "tsc && node ./scripts/gen-category-icons.mjs",
28
29
  "test": "vitest",
29
30
  "coverage": "vitest run --coverage",
30
31
  "format": "prettier --write .",
@@ -1,11 +0,0 @@
1
- import { type ReactNode } from 'react';
2
- import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.js';
3
- export type { AnnotationCanvasHandle, GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
4
- export interface CanvasKitOpts {
5
- locateFile?: (file: string) => string;
6
- }
7
- export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
8
- fallback?: ReactNode;
9
- canvasKitOpts?: CanvasKitOpts;
10
- };
11
- export declare const AnnotationCanvas: ({ fallback, canvasKitOpts, ...rest }: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,10 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { WithSkiaWeb } from '@shopify/react-native-skia/lib/module/web/index.js';
3
- // Web-only entry point: lazy-loads CanvasKit wasm via WithSkiaWeb. Imperative
4
- // API (undo/redo/zoom) is exposed through the `imperativeRef` prop rather
5
- // than React refs, since WithSkiaWeb can't forward refs through its lazy
6
- // component boundary. The native build sits in `AnnotationCanvas.native.tsx`
7
- // and skips WithSkiaWeb entirely (no wasm to load).
8
- export const AnnotationCanvas = ({ fallback, canvasKitOpts, ...rest }) => (_jsx(WithSkiaWeb, { getComponent: () => import('./AnnotationCanvasInner.js').then((m) => ({
9
- default: m.AnnotationCanvasInner,
10
- })), fallback: fallback ?? null, componentProps: rest, opts: canvasKitOpts }));
@@ -1,8 +0,0 @@
1
- import type { AnnotationCanvasInnerProps } from './AnnotationCanvasInner.native.js';
2
- export type { AnnotationCanvasHandle, } from './useAnnotationCanvasState.js';
3
- export type { GestureConfig, PanTrigger, } from './AnnotationCanvasInner.js';
4
- export type AnnotationCanvasProps = AnnotationCanvasInnerProps & {
5
- fallback?: unknown;
6
- canvasKitOpts?: unknown;
7
- };
8
- export declare const AnnotationCanvas: (props: AnnotationCanvasProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,6 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { AnnotationCanvasInner } from './AnnotationCanvasInner.native.js';
3
- export const AnnotationCanvas = (props) => {
4
- const { fallback: _f, canvasKitOpts: _o, ...rest } = props;
5
- return _jsx(AnnotationCanvasInner, { ...rest });
6
- };
@@ -1,39 +0,0 @@
1
- import { type CSSProperties, type MutableRefObject } from 'react';
2
- import type { DecimalTolerance, FractionalTolerance, Measurement, Units } from '../types/firestore.js';
3
- import type { AnnotationCanvasState, AnnotationDocumentPatch, Selection } from '../types/annotation.js';
4
- import type { MeasurementRef } from './measurementPicker.js';
5
- import type { Tool } from './Tool.js';
6
- import { type AnnotationCanvasHandle } from './useAnnotationCanvasState.js';
7
- import { type ViewportState } from './viewport.js';
8
- import type { RenderMeasurementStamp } from './measurementStampOverlay.js';
9
- export type { AnnotationCanvasHandle };
10
- export interface AnnotationCanvasInnerProps {
11
- canvas: AnnotationCanvasState;
12
- onCommit(patch: AnnotationDocumentPatch): void;
13
- tools: Tool[];
14
- activeToolId: string;
15
- selection: Selection | null;
16
- onSelectionChange(selection: Selection | null): void;
17
- measurements?: Measurement[];
18
- fallbackUnit?: Units;
19
- fractionalTolerance?: FractionalTolerance;
20
- decimalTolerance?: DecimalTolerance;
21
- resolveImageUrl?: (storagePath: string) => Promise<string>;
22
- pickMeasurement?: () => Promise<MeasurementRef | null>;
23
- renderMeasurementStamp?: RenderMeasurementStamp;
24
- stampFontSource?: unknown;
25
- stampValueFontSize?: number;
26
- stampLabelFontSize?: number;
27
- gestures?: GestureConfig;
28
- width: number;
29
- height: number;
30
- initialViewport?: ViewportState;
31
- style?: CSSProperties;
32
- imperativeRef?: MutableRefObject<AnnotationCanvasHandle | null>;
33
- }
34
- export type PanTrigger = 'middleMouse' | 'rightMouse' | 'space';
35
- export interface GestureConfig {
36
- wheel?: 'zoom' | 'pan' | 'auto';
37
- panTriggers?: PanTrigger[];
38
- }
39
- export declare const AnnotationCanvasInner: (props: AnnotationCanvasInnerProps) => import("react/jsx-runtime").JSX.Element;