compostjs 0.1.0 → 0.2.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.
@@ -0,0 +1,25 @@
1
+ name: Test
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-dotnet@v4
16
+ with:
17
+ dotnet-version: '8.0.x'
18
+
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: '22'
22
+
23
+ - run: dotnet tool restore
24
+ - run: npm install
25
+ - run: npm test
package/CLAUDE.md ADDED
@@ -0,0 +1,78 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Build Commands
6
+
7
+ Prerequisites: .NET SDK 8+, Node.js, npm. Fable is installed as a dotnet local tool (`.config/dotnet-tools.json`).
8
+
9
+ ```bash
10
+ dotnet tool restore # Install Fable 4.28.0
11
+ npm install # Install Vite + virtual-dom
12
+
13
+ npm start # Dev server at http://localhost:8080 (Fable watch + Vite)
14
+ npm run build # Compile F# to ES modules in dist/ (for npm package)
15
+ npm run rebuild # Clean dist/ and rebuild
16
+ npm run standalone # Bundle standalone IIFE to docs/releases/
17
+ npm test # Compile F# then run Vitest tests (test/*.test.js)
18
+
19
+ npm run release # Full release (runs the three steps below)
20
+ npm run release:version # Bump version in package.json, create git commit + tag (via np)
21
+ npm run release:standalone # Build standalone bundle and commit to git
22
+ npm run release:publish # Rebuild dist/ and npm publish (prompts for 2FA)
23
+ ```
24
+
25
+ Tests use Vitest against the Fable-compiled JS output. F# must be compiled before tests run (`npm test` handles this). Test files go in `test/`. Use `c.foldDom(f, acc, node)` to traverse `DomNode` trees in tests — the callback receives `(acc, tag, attrs)` where `attrs` is a plain JS object of string attribute values. CI runs `npm test` on pushes and PRs to `master` via GitHub Actions (`.github/workflows/test.yml`).
26
+
27
+ ## Architecture
28
+
29
+ Compost.js is a composable data visualization library. The core is written in **F#** and compiled to **JavaScript** via **Fable 4**. The runtime dependency is `virtual-dom` for efficient DOM updates in interactive charts.
30
+
31
+ ### F# Source (`src/compost/`)
32
+
33
+ Three files, compiled in this order (defined in `compost.fsproj`):
34
+
35
+ 1. **`html.fs`** — `Compost.Html` module. Low-level JS interop layer:
36
+ - `Common` module: `[<Emit>]`-based helpers for JS operations (property access, typeof, date formatting)
37
+ - `Virtualdom` module: `[<Import>]` bindings to `virtual-dom` (h, diff, patch, createElement)
38
+ - `DomNode`/`DomAttribute` types and HTML/SVG rendering
39
+ - `foldDom`: generic fold over `DomNode` trees (used in tests to inspect rendered SVG)
40
+ - `createVirtualDomApp`: stateful interactive app loop using virtual-dom diffing
41
+
42
+ 2. **`core.fs`** — `Compost` namespace. The visualization engine (~800 lines):
43
+ - **Domain types**: `Value<'u>` (continuous `COV` or categorical `CAR`), `Scale<'v>`, `Shape<'vx,'vy>`, `Style`, `EventHandler`
44
+ - **Shape** is a discriminated union (Line, Bubble, Shape, Text, Image, Layered, Interactive, Axes, NestX/Y, Padding, etc.) — this is the core composable DSL
45
+ - Uses F# units of measure (`[<Measure>]`) for type-safe coordinate spaces
46
+ - `Scales` module: axis generation, range calculation
47
+ - `Projections` module: coordinate transformation (data space → pixel space)
48
+ - `Drawing` module: converts shapes → SVG elements
49
+ - `Events` module: routes mouse/touch events to interactive handlers
50
+ - `Derived` module: higher-level combinators (FillColor, StrokeColor, Column, Bar, Area)
51
+
52
+ 3. **`compost.fs`** — `main` module. The JavaScript API surface:
53
+ - Exposes `scale` (JsScale) and `compost` (JsCompost) objects
54
+ - Handles JS↔F# value conversion via `parseValue`/`formatValue` (numbers become `COV`, `[string, number]` arrays become `CAR`)
55
+ - All ~20 API methods (`render`, `interactive`, `overlay`, `axes`, `on`, etc.) are defined here
56
+
57
+ ### JavaScript Entry Points (`src/project/`)
58
+
59
+ - **`standalone.js`** — Imports `scale`/`compost` from F# and assigns to `window.s`/`window.c`
60
+ - **`demos.js`** — Interactive demo examples used by the dev server (`index.html`)
61
+ - **`data.js`** — Demo datasets (elections, exchange rates, iris)
62
+
63
+ ### Build Outputs
64
+
65
+ - **`dist/`** — ES modules for npm (`compost.js` is the entry point, `"main"` in package.json)
66
+ - **`docs/releases/`** — Standalone IIFE bundles for `<script>` tag usage
67
+
68
+ ### How Fable Compilation Works
69
+
70
+ `dotnet fable` compiles `.fs` files to `.fs.js` files. During `npm start` (watch mode), these appear next to the source files (`src/compost/*.fs.js`). During `npm run build`, they go to `dist/` via the `-o` flag. Fable library dependencies go into `fable_modules/`. Vite (or any bundler) then treats the `.js` output as standard ES modules.
71
+
72
+ ## Key Patterns
73
+
74
+ - The JS API uses `obj` and `box`/`unbox` heavily for dynamic typing at the JS boundary. The F# internals are fully typed with units of measure.
75
+ - `[<Emit("...")>]` is used for inline JS expressions (property access, typeof, Date operations).
76
+ - `[<Import("name","module")>]` is used for virtual-dom imports.
77
+ - `JsInterop.createObj` creates plain JS objects from F# key-value sequences.
78
+ - Interactive charts use a virtual-dom update cycle: event → update function → render function → diff/patch.
package/README.md CHANGED
@@ -33,6 +33,20 @@ JavaScript file that is added to the `releases` folder of the `docs` with the cu
33
33
  version number in the filename (and also updates the `latest` file).
34
34
  This should all happen automatically when using `npm run release`.
35
35
 
36
+ ### Releasing Compost
37
+
38
+ Before releasing, make sure all changes are committed. Then run:
39
+
40
+ ```
41
+ npm run release
42
+ ```
43
+
44
+ This runs three steps in order:
45
+
46
+ 1. **`npm run release:version`** — bumps the version in `package.json` and creates a git commit and tag (via `np`)
47
+ 2. **`npm run release:standalone`** — builds the standalone bundle and commits it to git
48
+ 3. **`npm run release:publish`** — rebuilds `dist/` and publishes to npm (prompts for 2FA)
49
+
36
50
  ## What is the story behind the name??
37
51
 
38
52
  ![Compost](https://github.com/compostjs/compost/raw/master/compost.gif)
package/dist/compost.js CHANGED
@@ -1,10 +1,10 @@
1
- import { Compost_createSvg, EventHandler, HorizontalAlign, VerticalAlign, Derived_Bar, Derived_Column, Derived_Font, Derived_StrokeColor, Derived_PreserveAspectRatio, Derived_FillColor, Shape, Scale, categorical, Value, continuous } from "./core.js";
2
- import { printf, toFail } from "./fable_modules/fable-library-js.4.28.0/String.js";
1
+ import { Compost_createSvg, EventHandler, Derived_Bar, Derived_Column, Derived_Font, Derived_StrokeColor, Derived_PreserveAspectRatio, Derived_FillColor, Shape, StyleConfig, Scale, HorizontalAlign, VerticalAlign, categorical, Value, continuous } from "./core.js";
2
+ import { join, printf, toFail } from "./fable_modules/fable-library-js.4.28.0/String.js";
3
3
  import { map as map_1, item } from "./fable_modules/fable-library-js.4.28.0/Array.js";
4
- import { toList, map, delay, toArray } from "./fable_modules/fable-library-js.4.28.0/Seq.js";
5
- import { ofArray } from "./fable_modules/fable-library-js.4.28.0/List.js";
6
- import { defaultOf, equals } from "./fable_modules/fable-library-js.4.28.0/Util.js";
7
- import { renderTo, createVirtualDomApp, DomNode, DomAttribute } from "./html.js";
4
+ import { empty, append, toList, map, singleton, collect, delay, toArray } from "./fable_modules/fable-library-js.4.28.0/Seq.js";
5
+ import { ofArray, ofSeq } from "./fable_modules/fable-library-js.4.28.0/List.js";
6
+ import { createObj, equals, defaultOf } from "./fable_modules/fable-library-js.4.28.0/Util.js";
7
+ import { foldDom, renderTo, createVirtualDomApp, DomNode, DomAttribute } from "./html.js";
8
8
 
9
9
  export function Helpers_formatValue(v) {
10
10
  if (v.tag === 1) {
@@ -34,6 +34,282 @@ export function Helpers_parseValue(v) {
34
34
  }
35
35
  }
36
36
 
37
+ function Serialization_serValue() {
38
+ return Helpers_formatValue;
39
+ }
40
+
41
+ function Serialization_deserValue() {
42
+ return Helpers_parseValue;
43
+ }
44
+
45
+ function Serialization_serializeVerticalAlign(_arg) {
46
+ switch (_arg.tag) {
47
+ case 1:
48
+ return "middle";
49
+ case 2:
50
+ return "hanging";
51
+ default:
52
+ return "baseline";
53
+ }
54
+ }
55
+
56
+ function Serialization_deserializeVerticalAlign(_arg) {
57
+ switch (_arg) {
58
+ case "baseline":
59
+ return new VerticalAlign(0, []);
60
+ case "hanging":
61
+ return new VerticalAlign(2, []);
62
+ default:
63
+ return new VerticalAlign(1, []);
64
+ }
65
+ }
66
+
67
+ function Serialization_serializeHorizontalAlign(_arg) {
68
+ switch (_arg.tag) {
69
+ case 1:
70
+ return "center";
71
+ case 2:
72
+ return "end";
73
+ default:
74
+ return "start";
75
+ }
76
+ }
77
+
78
+ function Serialization_deserializeHorizontalAlign(_arg) {
79
+ switch (_arg) {
80
+ case "start":
81
+ return new HorizontalAlign(0, []);
82
+ case "end":
83
+ return new HorizontalAlign(2, []);
84
+ default:
85
+ return new HorizontalAlign(1, []);
86
+ }
87
+ }
88
+
89
+ function Serialization_serializeScale(_arg) {
90
+ if (_arg.tag === 1) {
91
+ return {
92
+ kind: "categorical",
93
+ cats: toArray(delay(() => collect((matchValue) => singleton(matchValue.fields[0]), _arg.fields[0]))),
94
+ };
95
+ }
96
+ else {
97
+ return {
98
+ kind: "continuous",
99
+ lo: _arg.fields[0].fields[0],
100
+ hi: _arg.fields[1].fields[0],
101
+ };
102
+ }
103
+ }
104
+
105
+ function Serialization_deserializeScale(o) {
106
+ const matchValue = o["kind"];
107
+ switch (matchValue) {
108
+ case "continuous":
109
+ return new Scale(0, [new continuous(o["lo"]), new continuous(o["hi"])]);
110
+ case "categorical":
111
+ return new Scale(1, [toArray(delay(() => map((c) => (new categorical(c)), o["cats"])))]);
112
+ default:
113
+ return toFail(printf("Unknown scale kind: %s"))(matchValue);
114
+ }
115
+ }
116
+
117
+ function Serialization_serializeStyleConfig(_arg) {
118
+ switch (_arg.tag) {
119
+ case 2:
120
+ return {
121
+ kind: "stroke",
122
+ color: _arg.fields[0],
123
+ };
124
+ case 3:
125
+ return {
126
+ kind: "font",
127
+ font: _arg.fields[0],
128
+ color: _arg.fields[1],
129
+ };
130
+ case 4:
131
+ return {
132
+ kind: "aspect",
133
+ value: _arg.fields[0],
134
+ };
135
+ case 0:
136
+ throw new Error("Cannot serialize Custom style config");
137
+ default:
138
+ return {
139
+ kind: "fill",
140
+ color: _arg.fields[0],
141
+ };
142
+ }
143
+ }
144
+
145
+ function Serialization_deserializeStyleConfig(o) {
146
+ const matchValue = o["kind"];
147
+ switch (matchValue) {
148
+ case "fill":
149
+ return new StyleConfig(1, [o["color"]]);
150
+ case "stroke":
151
+ return new StyleConfig(2, [o["color"]]);
152
+ case "font":
153
+ return new StyleConfig(3, [o["font"], o["color"]]);
154
+ case "aspect":
155
+ return new StyleConfig(4, [o["value"]]);
156
+ default:
157
+ return toFail(printf("Unknown style config kind: %s"))(matchValue);
158
+ }
159
+ }
160
+
161
+ function Serialization_serializePoints(pts) {
162
+ return toArray(delay(() => collect((matchValue) => singleton([Serialization_serValue()(matchValue[0]), Serialization_serValue()(matchValue[1])]), pts)));
163
+ }
164
+
165
+ export function Serialization_serializeShape(s) {
166
+ switch (s.tag) {
167
+ case 9:
168
+ return {
169
+ kind: "shape",
170
+ points: Serialization_serializePoints(ofSeq(s.fields[0])),
171
+ };
172
+ case 8:
173
+ return {
174
+ kind: "bubble",
175
+ x: Serialization_serValue()(s.fields[0]),
176
+ y: Serialization_serValue()(s.fields[1]),
177
+ w: s.fields[2],
178
+ h: s.fields[3],
179
+ };
180
+ case 2:
181
+ return {
182
+ kind: "text",
183
+ x: Serialization_serValue()(s.fields[0]),
184
+ y: Serialization_serValue()(s.fields[1]),
185
+ valign: Serialization_serializeVerticalAlign(s.fields[2]),
186
+ halign: Serialization_serializeHorizontalAlign(s.fields[3]),
187
+ rotation: s.fields[4],
188
+ text: s.fields[5],
189
+ };
190
+ case 0:
191
+ return {
192
+ kind: "image",
193
+ href: s.fields[0],
194
+ p1: [Serialization_serValue()(s.fields[1][0]), Serialization_serValue()(s.fields[1][1])],
195
+ p2: [Serialization_serValue()(s.fields[2][0]), Serialization_serValue()(s.fields[2][1])],
196
+ };
197
+ case 10:
198
+ return {
199
+ kind: "layered",
200
+ shapes: toArray(delay(() => map(Serialization_serializeShape, s.fields[0]))),
201
+ };
202
+ case 11:
203
+ return {
204
+ kind: "axes",
205
+ axes: join(" ", toList(delay(() => append(s.fields[0] ? singleton("top") : empty(), delay(() => append(s.fields[1] ? singleton("right") : empty(), delay(() => append(s.fields[2] ? singleton("bottom") : empty(), delay(() => (s.fields[3] ? singleton("left") : empty())))))))))),
206
+ shape: Serialization_serializeShape(s.fields[4]),
207
+ };
208
+ case 13:
209
+ return {
210
+ kind: "padding",
211
+ top: s.fields[0][0],
212
+ right: s.fields[0][1],
213
+ bottom: s.fields[0][2],
214
+ left: s.fields[0][3],
215
+ shape: Serialization_serializeShape(s.fields[1]),
216
+ };
217
+ case 5:
218
+ return {
219
+ kind: "nestx",
220
+ lx: Serialization_serValue()(s.fields[0]),
221
+ hx: Serialization_serValue()(s.fields[1]),
222
+ shape: Serialization_serializeShape(s.fields[2]),
223
+ };
224
+ case 6:
225
+ return {
226
+ kind: "nesty",
227
+ ly: Serialization_serValue()(s.fields[0]),
228
+ hy: Serialization_serValue()(s.fields[1]),
229
+ shape: Serialization_serializeShape(s.fields[2]),
230
+ };
231
+ case 4: {
232
+ const sy = s.fields[1];
233
+ const sx = s.fields[0];
234
+ return {
235
+ kind: "scale",
236
+ sx: (sx == null) ? defaultOf() : Serialization_serializeScale(sx),
237
+ sy: (sy == null) ? defaultOf() : Serialization_serializeScale(sy),
238
+ shape: Serialization_serializeShape(s.fields[2]),
239
+ };
240
+ }
241
+ case 1:
242
+ return {
243
+ kind: "styled",
244
+ param: Serialization_serializeStyleConfig(s.fields[0]),
245
+ shape: Serialization_serializeShape(s.fields[1]),
246
+ };
247
+ case 3:
248
+ return {
249
+ kind: "autoscale",
250
+ x: s.fields[0],
251
+ y: s.fields[1],
252
+ shape: Serialization_serializeShape(s.fields[2]),
253
+ };
254
+ case 14:
255
+ return {
256
+ kind: "offset",
257
+ dx: s.fields[0][0],
258
+ dy: s.fields[0][1],
259
+ shape: Serialization_serializeShape(s.fields[1]),
260
+ };
261
+ case 12:
262
+ throw new Error("Cannot serialize Interactive shapes");
263
+ default:
264
+ return {
265
+ kind: "line",
266
+ points: Serialization_serializePoints(ofSeq(s.fields[0])),
267
+ };
268
+ }
269
+ }
270
+
271
+ export function Serialization_deserializeShape(o) {
272
+ const matchValue = o["kind"];
273
+ switch (matchValue) {
274
+ case "line":
275
+ return new Shape(7, [toList(delay(() => map((p) => [Serialization_deserValue()(item(0, p)), Serialization_deserValue()(item(1, p))], o["points"])))]);
276
+ case "shape":
277
+ return new Shape(9, [toList(delay(() => map((p_1) => [Serialization_deserValue()(item(0, p_1)), Serialization_deserValue()(item(1, p_1))], o["points"])))]);
278
+ case "bubble":
279
+ return new Shape(8, [Serialization_deserValue()(o["x"]), Serialization_deserValue()(o["y"]), o["w"], o["h"]]);
280
+ case "text":
281
+ return new Shape(2, [Serialization_deserValue()(o["x"]), Serialization_deserValue()(o["y"]), Serialization_deserializeVerticalAlign(o["valign"]), Serialization_deserializeHorizontalAlign(o["halign"]), o["rotation"], o["text"]]);
282
+ case "image": {
283
+ const matchValue_1 = o["p1"];
284
+ const p2 = o["p2"];
285
+ const p1 = matchValue_1;
286
+ return new Shape(0, [o["href"], [Serialization_deserValue()(item(0, p1)), Serialization_deserValue()(item(1, p1))], [Serialization_deserValue()(item(0, p2)), Serialization_deserValue()(item(1, p2))]]);
287
+ }
288
+ case "layered":
289
+ return new Shape(10, [toList(delay(() => map(Serialization_deserializeShape, o["shapes"])))]);
290
+ case "axes": {
291
+ const a = o["axes"];
292
+ return new Shape(11, [a.indexOf("top") >= 0, a.indexOf("right") >= 0, a.indexOf("bottom") >= 0, a.indexOf("left") >= 0, Serialization_deserializeShape(o["shape"])]);
293
+ }
294
+ case "padding":
295
+ return new Shape(13, [[o["top"], o["right"], o["bottom"], o["left"]], Serialization_deserializeShape(o["shape"])]);
296
+ case "nestx":
297
+ return new Shape(5, [Serialization_deserValue()(o["lx"]), Serialization_deserValue()(o["hx"]), Serialization_deserializeShape(o["shape"])]);
298
+ case "nesty":
299
+ return new Shape(6, [Serialization_deserValue()(o["ly"]), Serialization_deserValue()(o["hy"]), Serialization_deserializeShape(o["shape"])]);
300
+ case "scale":
301
+ return new Shape(4, [equals(o["sx"], defaultOf()) ? undefined : Serialization_deserializeScale(o["sx"]), equals(o["sy"], defaultOf()) ? undefined : Serialization_deserializeScale(o["sy"]), Serialization_deserializeShape(o["shape"])]);
302
+ case "styled":
303
+ return new Shape(1, [Serialization_deserializeStyleConfig(o["param"]), Serialization_deserializeShape(o["shape"])]);
304
+ case "autoscale":
305
+ return new Shape(3, [o["x"], o["y"], Serialization_deserializeShape(o["shape"])]);
306
+ case "offset":
307
+ return new Shape(14, [[o["dx"], o["dy"]], Serialization_deserializeShape(o["shape"])]);
308
+ default:
309
+ return toFail(printf("Unknown shape kind: %s"))(matchValue);
310
+ }
311
+ }
312
+
37
313
  export const scale = {
38
314
  continuous(lo, hi) {
39
315
  return new Scale(0, [new continuous(lo), new continuous(hi)]);
@@ -170,5 +446,22 @@ export const compost = {
170
446
  const el_1 = document.getElementById(id_1);
171
447
  renderTo(el_1, Compost_createSvg(false, false, el_1.clientWidth, el_1.clientHeight, viz));
172
448
  },
449
+ foldDom(f_3, acc, node) {
450
+ return foldDom((acc_1, _ns, tag_1, attrs_3) => f_3(acc_1, tag_1, createObj(toArray(delay(() => collect((matchValue) => {
451
+ const matchValue_1 = matchValue[1];
452
+ if (matchValue_1.tag === 1) {
453
+ return singleton([matchValue[0], matchValue_1.fields[0]]);
454
+ }
455
+ else {
456
+ return empty();
457
+ }
458
+ }, attrs_3))))), acc, node);
459
+ },
460
+ serialize(s_15) {
461
+ return Serialization_serializeShape(s_15);
462
+ },
463
+ deserialize(o_1) {
464
+ return Serialization_deserializeShape(o_1);
465
+ },
173
466
  };
174
467
 
package/dist/core.js CHANGED
@@ -183,6 +183,38 @@ export function Style_$reflection() {
183
183
  return record_type("Compost.Style", [], Style, () => [["StrokeColor", tuple_type(float64_type, Color_$reflection())], ["StrokeWidth", Width_$reflection()], ["StrokeDashArray", class_type("System.Collections.Generic.IEnumerable`1", [Number$_$reflection()])], ["Fill", FillStyle_$reflection()], ["Animation", option_type(tuple_type(int32_type, string_type, lambda_type(Style_$reflection(), Style_$reflection())))], ["Font", string_type], ["Cursor", string_type], ["PreserveAspectRatio", string_type], ["FormatAxisXLabel", lambda_type(Scale_$reflection(class_type("Microsoft.FSharp.Core.CompilerServices.MeasureOne")), lambda_type(Value_$reflection(class_type("Microsoft.FSharp.Core.CompilerServices.MeasureOne")), string_type))], ["FormatAxisYLabel", lambda_type(Scale_$reflection(class_type("Microsoft.FSharp.Core.CompilerServices.MeasureOne")), lambda_type(Value_$reflection(class_type("Microsoft.FSharp.Core.CompilerServices.MeasureOne")), string_type))]]);
184
184
  }
185
185
 
186
+ export class StyleConfig extends Union {
187
+ constructor(tag, fields) {
188
+ super();
189
+ this.tag = tag;
190
+ this.fields = fields;
191
+ }
192
+ cases() {
193
+ return ["Custom", "FillColor", "StrokeColor", "Font", "PreserveAspectRatio"];
194
+ }
195
+ }
196
+
197
+ export function StyleConfig_$reflection() {
198
+ return union_type("Compost.StyleConfig", [], StyleConfig, () => [[["Item", lambda_type(Style_$reflection(), Style_$reflection())]], [["Item", string_type]], [["Item", string_type]], [["Item1", string_type], ["Item2", string_type]], [["Item", string_type]]]);
199
+ }
200
+
201
+ export function StyleConfig__Apply_4E2CA0C5(this$, style) {
202
+ switch (this$.tag) {
203
+ case 1:
204
+ return new Style(style.StrokeColor, style.StrokeWidth, style.StrokeDashArray, new FillStyle(0, [[1, new Color(1, [this$.fields[0]])]]), style.Animation, style.Font, style.Cursor, style.PreserveAspectRatio, style.FormatAxisXLabel, style.FormatAxisYLabel);
205
+ case 2:
206
+ return new Style([1, new Color(1, [this$.fields[0]])], style.StrokeWidth, style.StrokeDashArray, style.Fill, style.Animation, style.Font, style.Cursor, style.PreserveAspectRatio, style.FormatAxisXLabel, style.FormatAxisYLabel);
207
+ case 3: {
208
+ const clr_2 = this$.fields[1];
209
+ return new Style([0, new Color(1, [clr_2])], style.StrokeWidth, style.StrokeDashArray, new FillStyle(0, [[1, new Color(1, [clr_2])]]), style.Animation, this$.fields[0], style.Cursor, style.PreserveAspectRatio, style.FormatAxisXLabel, style.FormatAxisYLabel);
210
+ }
211
+ case 4:
212
+ return new Style(style.StrokeColor, style.StrokeWidth, style.StrokeDashArray, style.Fill, style.Animation, style.Font, style.Cursor, this$.fields[0], style.FormatAxisXLabel, style.FormatAxisYLabel);
213
+ default:
214
+ return this$.fields[0](style);
215
+ }
216
+ }
217
+
186
218
  export class EventHandler extends Union {
187
219
  constructor(tag, fields) {
188
220
  super();
@@ -225,7 +257,7 @@ export class Shape extends Union {
225
257
  }
226
258
 
227
259
  export function Shape_$reflection(gen0, gen1) {
228
- return union_type("Compost.Shape", [gen0, gen1], Shape, () => [[["Item1", string_type], ["Item2", tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))], ["Item3", tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))]], [["Item1", lambda_type(Style_$reflection(), Style_$reflection())], ["Item2", Shape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen1)], ["Item3", VerticalAlign_$reflection()], ["Item4", HorizontalAlign_$reflection()], ["Item5", float64_type], ["Item6", string_type]], [["Item1", bool_type], ["Item2", bool_type], ["Item3", Shape_$reflection(gen0, gen1)]], [["Item1", option_type(Scale_$reflection(gen0))], ["Item2", option_type(Scale_$reflection(gen1))], ["Item3", Shape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen0)], ["Item3", Shape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen1)], ["Item2", Value_$reflection(gen1)], ["Item3", Shape_$reflection(gen0, gen1)]], [["Item", class_type("System.Collections.Generic.IEnumerable`1", [tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))])]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen1)], ["Item3", float64_type], ["Item4", float64_type]], [["Item", class_type("System.Collections.Generic.IEnumerable`1", [tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))])]], [["Item", class_type("System.Collections.Generic.IEnumerable`1", [Shape_$reflection(gen0, gen1)])]], [["Item1", bool_type], ["Item2", bool_type], ["Item3", bool_type], ["Item4", bool_type], ["Item5", Shape_$reflection(gen0, gen1)]], [["Item1", class_type("System.Collections.Generic.IEnumerable`1", [EventHandler_$reflection(gen0, gen1)])], ["Item2", Shape_$reflection(gen0, gen1)]], [["Item1", tuple_type(float64_type, float64_type, float64_type, float64_type)], ["Item2", Shape_$reflection(gen0, gen1)]], [["Item1", tuple_type(float64_type, float64_type)], ["Item2", Shape_$reflection(gen0, gen1)]]]);
260
+ return union_type("Compost.Shape", [gen0, gen1], Shape, () => [[["Item1", string_type], ["Item2", tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))], ["Item3", tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))]], [["Item1", StyleConfig_$reflection()], ["Item2", Shape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen1)], ["Item3", VerticalAlign_$reflection()], ["Item4", HorizontalAlign_$reflection()], ["Item5", float64_type], ["Item6", string_type]], [["Item1", bool_type], ["Item2", bool_type], ["Item3", Shape_$reflection(gen0, gen1)]], [["Item1", option_type(Scale_$reflection(gen0))], ["Item2", option_type(Scale_$reflection(gen1))], ["Item3", Shape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen0)], ["Item3", Shape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen1)], ["Item2", Value_$reflection(gen1)], ["Item3", Shape_$reflection(gen0, gen1)]], [["Item", class_type("System.Collections.Generic.IEnumerable`1", [tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))])]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen1)], ["Item3", float64_type], ["Item4", float64_type]], [["Item", class_type("System.Collections.Generic.IEnumerable`1", [tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))])]], [["Item", class_type("System.Collections.Generic.IEnumerable`1", [Shape_$reflection(gen0, gen1)])]], [["Item1", bool_type], ["Item2", bool_type], ["Item3", bool_type], ["Item4", bool_type], ["Item5", Shape_$reflection(gen0, gen1)]], [["Item1", class_type("System.Collections.Generic.IEnumerable`1", [EventHandler_$reflection(gen0, gen1)])], ["Item2", Shape_$reflection(gen0, gen1)]], [["Item1", tuple_type(float64_type, float64_type, float64_type, float64_type)], ["Item2", Shape_$reflection(gen0, gen1)]], [["Item1", tuple_type(float64_type, float64_type)], ["Item2", Shape_$reflection(gen0, gen1)]]]);
229
261
  }
230
262
 
231
263
  export class Svg_StringBuilder {
@@ -407,7 +439,7 @@ export class Scales_ScaledShape extends Union {
407
439
  }
408
440
 
409
441
  export function Scales_ScaledShape_$reflection(gen0, gen1) {
410
- return union_type("Compost.Scales.ScaledShape", [gen0, gen1], Scales_ScaledShape, () => [[["Item1", lambda_type(Style_$reflection(), Style_$reflection())], ["Item2", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen1)], ["Item3", VerticalAlign_$reflection()], ["Item4", HorizontalAlign_$reflection()], ["Item5", float64_type], ["Item6", string_type]], [["Item", array_type(tuple_type(Value_$reflection(gen0), Value_$reflection(gen1)))]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen1)], ["Item3", float64_type], ["Item4", float64_type]], [["Item", array_type(tuple_type(Value_$reflection(gen0), Value_$reflection(gen1)))]], [["Item1", string_type], ["Item2", tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))], ["Item3", tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))]], [["Item", array_type(Scales_ScaledShape_$reflection(gen0, gen1))]], [["Item1", class_type("System.Collections.Generic.IEnumerable`1", [EventHandler_$reflection(gen0, gen1)])], ["Item2", Scale_$reflection(gen0)], ["Item3", Scale_$reflection(gen1)], ["Item4", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", tuple_type(float64_type, float64_type, float64_type, float64_type)], ["Item2", Scale_$reflection(gen0)], ["Item3", Scale_$reflection(gen1)], ["Item4", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", tuple_type(float64_type, float64_type)], ["Item2", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen0)], ["Item3", Scale_$reflection(gen0)], ["Item4", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen1)], ["Item2", Value_$reflection(gen1)], ["Item3", Scale_$reflection(gen1)], ["Item4", Scales_ScaledShape_$reflection(gen0, gen1)]]]);
442
+ return union_type("Compost.Scales.ScaledShape", [gen0, gen1], Scales_ScaledShape, () => [[["Item1", StyleConfig_$reflection()], ["Item2", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen1)], ["Item3", VerticalAlign_$reflection()], ["Item4", HorizontalAlign_$reflection()], ["Item5", float64_type], ["Item6", string_type]], [["Item", array_type(tuple_type(Value_$reflection(gen0), Value_$reflection(gen1)))]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen1)], ["Item3", float64_type], ["Item4", float64_type]], [["Item", array_type(tuple_type(Value_$reflection(gen0), Value_$reflection(gen1)))]], [["Item1", string_type], ["Item2", tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))], ["Item3", tuple_type(Value_$reflection(gen0), Value_$reflection(gen1))]], [["Item", array_type(Scales_ScaledShape_$reflection(gen0, gen1))]], [["Item1", class_type("System.Collections.Generic.IEnumerable`1", [EventHandler_$reflection(gen0, gen1)])], ["Item2", Scale_$reflection(gen0)], ["Item3", Scale_$reflection(gen1)], ["Item4", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", tuple_type(float64_type, float64_type, float64_type, float64_type)], ["Item2", Scale_$reflection(gen0)], ["Item3", Scale_$reflection(gen1)], ["Item4", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", tuple_type(float64_type, float64_type)], ["Item2", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen0)], ["Item2", Value_$reflection(gen0)], ["Item3", Scale_$reflection(gen0)], ["Item4", Scales_ScaledShape_$reflection(gen0, gen1)]], [["Item1", Value_$reflection(gen1)], ["Item2", Value_$reflection(gen1)], ["Item3", Scale_$reflection(gen1)], ["Item4", Scales_ScaledShape_$reflection(gen0, gen1)]]]);
411
443
  }
412
444
 
413
445
  export function Scales_getExtremes(_arg) {
@@ -699,8 +731,8 @@ export function Scales_calculateScales(style, shape) {
699
731
  const lx = matchValue[0];
700
732
  const hy = matchValue_1[1];
701
733
  const hx = matchValue[1];
702
- const LineStyle = (clr, alpha, width, shape_18) => (new Shape(1, [(s) => (new Style([alpha, new Color(1, [clr])], new Width(width), s.StrokeDashArray, new FillStyle(0, [[1, new Color(1, ["transparent"])]]), s.Animation, s.Font, s.Cursor, s.PreserveAspectRatio, s.FormatAxisXLabel, s.FormatAxisYLabel)), shape_18]));
703
- const FontStyle = (style_2, shape_19) => (new Shape(1, [(s_1) => (new Style([0, new Color(1, ["transparent"])], s_1.StrokeWidth, s_1.StrokeDashArray, new FillStyle(0, [[1, new Color(1, ["black"])]]), s_1.Animation, style_2, s_1.Cursor, s_1.PreserveAspectRatio, s_1.FormatAxisXLabel, s_1.FormatAxisYLabel)), shape_19]));
734
+ const LineStyle = (clr, alpha, width, shape_18) => (new Shape(1, [new StyleConfig(0, [(s) => (new Style([alpha, new Color(1, [clr])], new Width(width), s.StrokeDashArray, new FillStyle(0, [[1, new Color(1, ["transparent"])]]), s.Animation, s.Font, s.Cursor, s.PreserveAspectRatio, s.FormatAxisXLabel, s.FormatAxisYLabel))]), shape_18]));
735
+ const FontStyle = (style_2, shape_19) => (new Shape(1, [new StyleConfig(0, [(s_1) => (new Style([0, new Color(1, ["transparent"])], s_1.StrokeWidth, s_1.StrokeDashArray, new FillStyle(0, [[1, new Color(1, ["black"])]]), s_1.Animation, style_2, s_1.Cursor, s_1.PreserveAspectRatio, s_1.FormatAxisXLabel, s_1.FormatAxisYLabel))]), shape_19]));
704
736
  return calculateScales(new Shape(13, [[showTop ? 30 : 0, showRight ? 50 : 0, showBottom ? 30 : 0, showLeft ? 50 : 0], new Shape(10, [toList(delay(() => append(singleton(new Shape(4, [sx_4, sy_4, new Shape(10, [toList(delay(() => append(map_1((x_2) => LineStyle("#e4e4e4", 1, 1, new Shape(7, [[[x_2, ly], [x_2, hy]]])), Scales_generateAxisSteps(sx_4)), delay(() => map_1((y_2) => LineStyle("#e4e4e4", 1, 1, new Shape(7, [[[lx, y_2], [hx, y_2]]])), Scales_generateAxisSteps(sy_4))))))])])), delay(() => append(showTop ? append(singleton(LineStyle("black", 1, 2, new Shape(7, [[[lx, hy], [hx, hy]]]))), delay(() => collect((matchValue_2) => singleton(FontStyle("9pt sans-serif", new Shape(14, [[0, -10], new Shape(2, [matchValue_2[0], hy, new VerticalAlign(0, []), new HorizontalAlign(1, []), 0, matchValue_2[1]])]))), Scales_generateAxisLabels(style.FormatAxisXLabel, sx_4)))) : empty_1(), delay(() => append(showRight ? append(singleton(LineStyle("black", 1, 2, new Shape(7, [[[hx, hy], [hx, ly]]]))), delay(() => collect((matchValue_3) => singleton(FontStyle("9pt sans-serif", new Shape(14, [[10, 0], new Shape(2, [hx, matchValue_3[0], new VerticalAlign(1, []), new HorizontalAlign(0, []), 0, matchValue_3[1]])]))), Scales_generateAxisLabels(style.FormatAxisYLabel, sy_4)))) : empty_1(), delay(() => append(showBottom ? append(singleton(LineStyle("black", 1, 2, new Shape(7, [[[lx, ly], [hx, ly]]]))), delay(() => collect((matchValue_4) => singleton(FontStyle("9pt sans-serif", new Shape(14, [[0, 10], new Shape(2, [matchValue_4[0], ly, new VerticalAlign(2, []), new HorizontalAlign(1, []), 0, matchValue_4[1]])]))), Scales_generateAxisLabels(style.FormatAxisXLabel, sx_4)))) : empty_1(), delay(() => append(showLeft ? append(singleton(LineStyle("black", 1, 2, new Shape(7, [[[lx, hy], [lx, ly]]]))), delay(() => collect((matchValue_5) => singleton(FontStyle("9pt sans-serif", new Shape(14, [[-10, 0], new Shape(2, [lx, matchValue_5[0], new VerticalAlign(1, []), new HorizontalAlign(2, []), 0, matchValue_5[1]])]))), Scales_generateAxisLabels(style.FormatAxisYLabel, sy_4)))) : empty_1(), delay(() => singleton(shape_17)))))))))))))])]));
705
737
  }
706
738
  case 10: {
@@ -715,9 +747,9 @@ export function Scales_calculateScales(style, shape) {
715
747
  return [scales_9, new Scales_ScaledShape(7, [shape.fields[0], scales_9[0], scales_9[1], patternInput_10[1]])];
716
748
  }
717
749
  default: {
718
- const f = shape.fields[0];
719
- const patternInput = Scales_calculateScales(f(style), shape.fields[1]);
720
- return [patternInput[0], new Scales_ScaledShape(0, [f, patternInput[1]])];
750
+ const sc = shape.fields[0];
751
+ const patternInput = Scales_calculateScales(StyleConfig__Apply_4E2CA0C5(sc, style), shape.fields[1]);
752
+ return [patternInput[0], new Scales_ScaledShape(0, [sc, patternInput[1]])];
721
753
  }
722
754
  }
723
755
  }
@@ -852,7 +884,7 @@ export function Drawing_drawShape(ctx_mut, area__mut, area__1_mut, area__2_mut,
852
884
  case 6:
853
885
  return new Svg_Svg(5, [map((shape_4) => Drawing_drawShape(ctx, area_1[0], area_1[1], area_1[2], area_1[3], scales_1[0], scales_1[1], shape_4), shape.fields[0])]);
854
886
  case 0: {
855
- ctx_mut = (new Drawing_DrawingContext(shape.fields[0](ctx.Style), ctx.Definitions));
887
+ ctx_mut = (new Drawing_DrawingContext(StyleConfig__Apply_4E2CA0C5(shape.fields[0], ctx.Style), ctx.Definitions));
856
888
  area__mut = area_1[0];
857
889
  area__1_mut = area_1[1];
858
890
  area__2_mut = area_1[2];
@@ -1373,19 +1405,19 @@ export function Events_triggerEvent(area__mut, area__1_mut, area__2_mut, area__3
1373
1405
  }
1374
1406
 
1375
1407
  export function Derived_PreserveAspectRatio(pa, s) {
1376
- return new Shape(1, [(s_1) => (new Style(s_1.StrokeColor, s_1.StrokeWidth, s_1.StrokeDashArray, s_1.Fill, s_1.Animation, s_1.Font, s_1.Cursor, pa, s_1.FormatAxisXLabel, s_1.FormatAxisYLabel)), s]);
1408
+ return new Shape(1, [new StyleConfig(4, [pa]), s]);
1377
1409
  }
1378
1410
 
1379
1411
  export function Derived_StrokeColor(clr, s) {
1380
- return new Shape(1, [(s_1) => (new Style([1, new Color(1, [clr])], s_1.StrokeWidth, s_1.StrokeDashArray, s_1.Fill, s_1.Animation, s_1.Font, s_1.Cursor, s_1.PreserveAspectRatio, s_1.FormatAxisXLabel, s_1.FormatAxisYLabel)), s]);
1412
+ return new Shape(1, [new StyleConfig(2, [clr]), s]);
1381
1413
  }
1382
1414
 
1383
1415
  export function Derived_FillColor(clr, s) {
1384
- return new Shape(1, [(s_1) => (new Style(s_1.StrokeColor, s_1.StrokeWidth, s_1.StrokeDashArray, new FillStyle(0, [[1, new Color(1, [clr])]]), s_1.Animation, s_1.Font, s_1.Cursor, s_1.PreserveAspectRatio, s_1.FormatAxisXLabel, s_1.FormatAxisYLabel)), s]);
1416
+ return new Shape(1, [new StyleConfig(1, [clr]), s]);
1385
1417
  }
1386
1418
 
1387
1419
  export function Derived_Font(font, clr, s) {
1388
- return new Shape(1, [(s_1) => (new Style([0, new Color(1, [clr])], s_1.StrokeWidth, s_1.StrokeDashArray, new FillStyle(0, [[1, new Color(1, [clr])]]), s_1.Animation, font, s_1.Cursor, s_1.PreserveAspectRatio, s_1.FormatAxisXLabel, s_1.FormatAxisYLabel)), s]);
1420
+ return new Shape(1, [new StyleConfig(3, [font, clr]), s]);
1389
1421
  }
1390
1422
 
1391
1423
  export function Derived_Area(line) {
package/dist/html.js CHANGED
@@ -7,7 +7,7 @@ import { equals, createAtom, defaultOf, createObj, disposeSafe, getEnumerator }
7
7
  import { array_type, tuple_type, union_type, obj_type, string_type, lambda_type, unit_type, class_type } from "./fable_modules/fable-library-js.4.28.0/Reflection.js";
8
8
  import { patch, diff, h as h_1 } from "virtual-dom";
9
9
  import { toArray as toArray_1, singleton, empty, append as append_1 } from "./fable_modules/fable-library-js.4.28.0/List.js";
10
- import { item, map as map_1 } from "./fable_modules/fable-library-js.4.28.0/Array.js";
10
+ import { fold, item, map as map_1 } from "./fable_modules/fable-library-js.4.28.0/Array.js";
11
11
  import { Event as Event$ } from "./fable_modules/fable-library-js.4.28.0/Event.js";
12
12
  import { startImmediate } from "./fable_modules/fable-library-js.4.28.0/Async.js";
13
13
  import { singleton as singleton_1 } from "./fable_modules/fable-library-js.4.28.0/AsyncBuilder.js";
@@ -212,6 +212,15 @@ export function createVirtualDomApp(id, initial, r, u) {
212
212
  }, event.Publish);
213
213
  }
214
214
 
215
+ export function foldDom(f, acc, node) {
216
+ if (node.tag === 1) {
217
+ return fold((acc_2, node_1) => foldDom(f, acc_2, node_1), f(acc, node.fields[0], node.fields[1], node.fields[2]), node.fields[3]);
218
+ }
219
+ else {
220
+ return acc;
221
+ }
222
+ }
223
+
215
224
  export function text(s_1) {
216
225
  return new DomNode(0, [s_1]);
217
226
  }
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "compostjs",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Composable data visualization library for JavaScript",
6
6
  "author": "Tomas Petricek",
7
7
  "license": "MIT",
8
8
  "scripts": {
9
9
  "start": "dotnet fable watch src/compost/compost.fsproj --run npx vite",
10
- "build": "dotnet fable src/compost/compost.fsproj -o dist && rm dist/fable_modules/.gitignore",
10
+ "build": "dotnet fable src/compost/compost.fsproj -o dist && rm -f dist/fable_modules/.gitignore",
11
11
  "rebuild": "rm -rf dist && npm run build",
12
12
  "standalone": "dotnet fable src/compost/compost.fsproj --run npx vite build && sh copy-latest.sh",
13
- "updatejs": "npm run standalone && git add . && git commit -m \"Update standalone release file in docs\"",
14
- "release": "np --yolo --no-release-draft --no-publish && npm run updatejs && npm run rebuild && npm publish"
13
+ "test": "dotnet fable src/compost/compost.fsproj && npx vitest run",
14
+ "release:version": "np --yolo --no-release-draft --no-publish",
15
+ "release:standalone": "npm run standalone && git add . && git commit -m \"Update standalone release file in docs\"",
16
+ "release:publish": "npm run rebuild && npm publish",
17
+ "release": "npm run release:version && npm run release:standalone && npm run release:publish"
15
18
  },
16
19
  "repository": {
17
20
  "type": "git",
@@ -32,6 +35,7 @@
32
35
  },
33
36
  "devDependencies": {
34
37
  "np": "^11.0.2",
35
- "vite": "^6.0.0"
38
+ "vite": "^6.0.0",
39
+ "vitest": "^4.0.18"
36
40
  }
37
41
  }
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { scale as s, compost as c } from '../src/compost/compost.fs.js';
3
+
4
+ // Collect all elements matching a predicate using the F# foldDom helper.
5
+ // The fold callback receives (acc, tag, attrs) where attrs is a plain
6
+ // object with string attribute values (Events/Properties are excluded).
7
+ function findElements(node, pred) {
8
+ return c.foldDom((acc, tag, attrs) => {
9
+ if (pred(tag, attrs)) acc.push({ tag, attrs });
10
+ return acc;
11
+ }, [], node);
12
+ }
13
+
14
+ // Parse an SVG path "d" attribute into an array of {cmd, x, y} objects.
15
+ // Handles format like "M0 200 L200 0 " where command letter is directly
16
+ // followed by the x coordinate.
17
+ function parsePath(d) {
18
+ const coords = [];
19
+ for (const m of d.matchAll(/([ML])(-?[\d.]+)\s+(-?[\d.]+)/g)) {
20
+ coords.push({ cmd: m[1], x: parseFloat(m[2]), y: parseFloat(m[3]) });
21
+ }
22
+ return coords;
23
+ }
24
+
25
+ describe('style rendering', () => {
26
+ it('renders a shape with fill color set', () => {
27
+ const sh = c.shape([[0, 0], [1, 0], [1, 1], [0, 1]]);
28
+ const svg = c.svg(200, 200, c.fillColor("red", sh));
29
+ const paths = findElements(svg, (tag) => tag === 'path');
30
+ expect(paths.length).toBe(1);
31
+ expect(paths[0].attrs.style).toContain('fill:red');
32
+ });
33
+
34
+ it('renders a line with stroke color set', () => {
35
+ const line = c.line([[0, 0], [1, 1]]);
36
+ const svg = c.svg(200, 200, c.strokeColor("blue", line));
37
+ const paths = findElements(svg, (tag) => tag === 'path');
38
+ expect(paths.length).toBe(1);
39
+ expect(paths[0].attrs.style).toContain('stroke:blue');
40
+ });
41
+ });
42
+
43
+ describe('svg rendering', () => {
44
+ it('renders a line from (0,0) to (1,1) as an SVG path', () => {
45
+ const line = c.line([[0, 0], [1, 1]]);
46
+ const svg = c.svg(200, 200, line);
47
+
48
+ const paths = findElements(svg, (tag) => tag === 'path');
49
+ expect(paths.length).toBe(1);
50
+
51
+ const coords = parsePath(paths[0].attrs.d);
52
+
53
+ // X: 0->0, 1->200. Y: 0->200, 1->0 (SVG Y-axis is inverted)
54
+ expect(coords).toHaveLength(2);
55
+ expect(coords[0]).toEqual({ cmd: 'M', x: 0, y: 200 });
56
+ expect(coords[1]).toEqual({ cmd: 'L', x: 200, y: 0 });
57
+ });
58
+
59
+ it('renders a column as a path filling the full SVG area', () => {
60
+ const col = c.column("test", 10);
61
+ const svg = c.svg(200, 200, col);
62
+
63
+ const paths = findElements(svg, (tag) => tag === 'path');
64
+ expect(paths.length).toBe(1);
65
+
66
+ const coords = parsePath(paths[0].attrs.d);
67
+
68
+ // Single column auto-scales to fill the entire 200x200 area
69
+ // Closed path covering all four corners
70
+ expect(coords).toHaveLength(5);
71
+ expect(coords[0]).toEqual({ cmd: 'M', x: 0, y: 0 });
72
+ expect(coords[1]).toEqual({ cmd: 'L', x: 200, y: 0 });
73
+ expect(coords[2]).toEqual({ cmd: 'L', x: 200, y: 200 });
74
+ expect(coords[3]).toEqual({ cmd: 'L', x: 0, y: 200 });
75
+ expect(coords[4]).toEqual({ cmd: 'L', x: 0, y: 0 });
76
+ });
77
+ });
78
+
79
+ describe('serialization', () => {
80
+ it('round-trips a line through serialize/deserialize', () => {
81
+ const line = c.line([[0, 0], [1, 1]]);
82
+ const line2 = c.deserialize(c.serialize(line));
83
+ const paths1 = findElements(c.svg(200, 200, line), (tag) => tag === 'path');
84
+ const paths2 = findElements(c.svg(200, 200, line2), (tag) => tag === 'path');
85
+ expect(paths2[0].attrs.d).toEqual(paths1[0].attrs.d);
86
+ });
87
+
88
+ it('round-trips a styled shape through serialize/deserialize', () => {
89
+ const shape = c.fillColor("red", c.strokeColor("blue", c.line([[0, 0], [1, 1]])));
90
+ const shape2 = c.deserialize(c.serialize(shape));
91
+ const paths1 = findElements(c.svg(200, 200, shape), (tag) => tag === 'path');
92
+ const paths2 = findElements(c.svg(200, 200, shape2), (tag) => tag === 'path');
93
+ expect(paths2[0].attrs.style).toEqual(paths1[0].attrs.style);
94
+ expect(paths2[0].attrs.d).toEqual(paths1[0].attrs.d);
95
+ });
96
+
97
+ it('round-trips a multi-series bar chart through serialize/deserialize', () => {
98
+ const chart = c.axes("left bottom",
99
+ c.scaleY(s.continuous(0, 50),
100
+ c.overlay([
101
+ c.padding(0, 5, 0, 5, c.fillColor("#DC4B4A", c.column("apples", 30))),
102
+ c.padding(0, 5, 0, 5, c.fillColor("#424498", c.column("plums", 20))),
103
+ c.padding(0, 5, 0, 5, c.fillColor("#A0CB5B", c.column("kiwi", 40))),
104
+ ])
105
+ )
106
+ );
107
+ const chart2 = c.deserialize(c.serialize(chart));
108
+ const paths1 = findElements(c.svg(400, 300, chart), (tag) => tag === 'path');
109
+ const paths2 = findElements(c.svg(400, 300, chart2), (tag) => tag === 'path');
110
+ expect(paths2.length).toBe(paths1.length);
111
+ paths1.forEach((p, i) => {
112
+ expect(paths2[i].attrs.d).toEqual(p.attrs.d);
113
+ expect(paths2[i].attrs.style).toEqual(p.attrs.style);
114
+ });
115
+ });
116
+ });