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.
- package/.github/workflows/test.yml +25 -0
- package/CLAUDE.md +78 -0
- package/README.md +14 -0
- package/dist/compost.js +299 -6
- package/dist/core.js +44 -12
- package/dist/html.js +10 -1
- package/package.json +9 -5
- package/test/compost.test.js +116 -0
|
@@ -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
|

|
package/dist/compost.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { Compost_createSvg, EventHandler,
|
|
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 {
|
|
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",
|
|
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",
|
|
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
|
|
719
|
-
const patternInput = Scales_calculateScales(
|
|
720
|
-
return [patternInput[0], new Scales_ScaledShape(0, [
|
|
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]
|
|
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, [
|
|
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, [
|
|
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, [
|
|
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, [
|
|
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.
|
|
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
|
-
"
|
|
14
|
-
"release": "np --yolo --no-release-draft --no-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
|
+
});
|