├── .envrc ├── .gitignore ├── .npmignore ├── README.md ├── common └── api.ts ├── flake.lock ├── flake.nix ├── package.json ├── server ├── builder.ts ├── index.ts └── persistence.ts ├── src ├── App.tsx ├── Button.tsx ├── Editor.tsx ├── Pane.tsx ├── Preview.tsx ├── Quiver.tsx ├── TopBar.tsx ├── YSignal.ts ├── cmdk.css ├── cmdk.tsx ├── custom.css ├── index.html ├── index.tsx ├── public │ └── icons │ │ ├── about.svg │ │ ├── centre-view.svg │ │ ├── delete.svg │ │ ├── deselect-all.svg │ │ ├── flip-hor.svg │ │ ├── flip-ver.svg │ │ ├── hide-grid.svg │ │ ├── pullback-checked.svg │ │ ├── pullback-unchecked.svg │ │ ├── redo.svg │ │ ├── reset-zoom.svg │ │ ├── rotate.svg │ │ ├── save.svg │ │ ├── select-all.svg │ │ ├── shortcuts.svg │ │ ├── show-hints.svg │ │ ├── show-queue.svg │ │ ├── transform.svg │ │ ├── undo.svg │ │ ├── var-pullback-checked.svg │ │ ├── var-pullback-unchecked.svg │ │ ├── zoom-in.svg │ │ └── zoom-out.svg ├── quiver │ ├── arrow.js │ ├── curve.js │ ├── dom.js │ ├── ds.js │ ├── icon.png │ ├── index.html │ ├── main.css │ ├── manifest.json │ ├── parser.js │ ├── quiver-blue.svg │ ├── quiver.js │ ├── quiver.svg │ ├── tests │ │ ├── arrow.html │ │ └── parser.tex │ └── ui.js ├── style.css └── styles │ └── anim.css ├── tsconfig.json ├── tsconfig.node.json ├── uno.config.ts ├── vite.config.ts └── yarn.lock /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | /.direnv/ 26 | *.sqlite 27 | /result 28 | /state.db 29 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | *.local 12 | 13 | # Editor directories and files 14 | .vscode/* 15 | !.vscode/extensions.json 16 | .idea 17 | .DS_Store 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | /.direnv/ 24 | *.sqlite 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | See [the webpage](https://forest.localcharts.org/silviculture-0001.xml). 2 | -------------------------------------------------------------------------------- /common/api.ts: -------------------------------------------------------------------------------- 1 | export type BuildSuccess = { 2 | success: true, 3 | content: string 4 | } 5 | 6 | export type BuildFailure = { 7 | success: false, 8 | stdout: string, 9 | stderr: string 10 | } 11 | 12 | export type BuildResult = BuildSuccess | BuildFailure 13 | 14 | export type PreviewRequest = { 15 | tree: string 16 | } 17 | 18 | export type BuildNotificationTag = 'building' | 'finished' 19 | 20 | export type BuildState = 'unbuilt' | 'building' | 'built' 21 | 22 | type Building = { 23 | state: 'building' 24 | } 25 | 26 | type Finished = { 27 | state: 'finished', 28 | result: BuildResult 29 | } 30 | 31 | export type BuildNotification = Building | Finished 32 | 33 | export type NewTreeRequest = { 34 | namespace: string, 35 | taxon?: string, 36 | title?: string 37 | } 38 | 39 | export type NewTreeResponse = { 40 | name: string 41 | } 42 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "bunnycdn-cli": { 4 | "inputs": { 5 | "napalm": "napalm", 6 | "nixpkgs": "nixpkgs" 7 | }, 8 | "locked": { 9 | "lastModified": 1709245855, 10 | "narHash": "sha256-/OX68BSC4kDQJ6sJMxcvwP+eZqPbKZ8+KMipnP6chQU=", 11 | "owner": "olynch", 12 | "repo": "bunnycdn-cli", 13 | "rev": "5c88b79340d619ca3bc67ebd80be57f1e7448df2", 14 | "type": "github" 15 | }, 16 | "original": { 17 | "owner": "olynch", 18 | "repo": "bunnycdn-cli", 19 | "type": "github" 20 | } 21 | }, 22 | "flake-compat": { 23 | "flake": false, 24 | "locked": { 25 | "lastModified": 1627913399, 26 | "narHash": "sha256-hY8g6H2KFL8ownSiFeMOjwPC8P0ueXpCVEbxgda3pko=", 27 | "owner": "edolstra", 28 | "repo": "flake-compat", 29 | "rev": "12c64ca55c1014cdc1b16ed5a804aa8576601ff2", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "edolstra", 34 | "repo": "flake-compat", 35 | "type": "github" 36 | } 37 | }, 38 | "flake-utils": { 39 | "inputs": { 40 | "systems": "systems" 41 | }, 42 | "locked": { 43 | "lastModified": 1701680307, 44 | "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", 45 | "owner": "numtide", 46 | "repo": "flake-utils", 47 | "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", 48 | "type": "github" 49 | }, 50 | "original": { 51 | "owner": "numtide", 52 | "repo": "flake-utils", 53 | "type": "github" 54 | } 55 | }, 56 | "flake-utils_2": { 57 | "inputs": { 58 | "systems": "systems_2" 59 | }, 60 | "locked": { 61 | "lastModified": 1701680307, 62 | "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", 63 | "owner": "numtide", 64 | "repo": "flake-utils", 65 | "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", 66 | "type": "github" 67 | }, 68 | "original": { 69 | "id": "flake-utils", 70 | "type": "indirect" 71 | } 72 | }, 73 | "flake-utils_3": { 74 | "inputs": { 75 | "systems": "systems_3" 76 | }, 77 | "locked": { 78 | "lastModified": 1701680307, 79 | "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", 80 | "owner": "numtide", 81 | "repo": "flake-utils", 82 | "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", 83 | "type": "github" 84 | }, 85 | "original": { 86 | "owner": "numtide", 87 | "repo": "flake-utils", 88 | "type": "github" 89 | } 90 | }, 91 | "flake-utils_4": { 92 | "inputs": { 93 | "systems": "systems_4" 94 | }, 95 | "locked": { 96 | "lastModified": 1681202837, 97 | "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", 98 | "owner": "numtide", 99 | "repo": "flake-utils", 100 | "rev": "cfacdce06f30d2b68473a46042957675eebb3401", 101 | "type": "github" 102 | }, 103 | "original": { 104 | "owner": "numtide", 105 | "repo": "flake-utils", 106 | "type": "github" 107 | } 108 | }, 109 | "flake-utils_5": { 110 | "inputs": { 111 | "systems": "systems_5" 112 | }, 113 | "locked": { 114 | "lastModified": 1705309234, 115 | "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", 116 | "owner": "numtide", 117 | "repo": "flake-utils", 118 | "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", 119 | "type": "github" 120 | }, 121 | "original": { 122 | "owner": "numtide", 123 | "repo": "flake-utils", 124 | "type": "github" 125 | } 126 | }, 127 | "flake-utils_6": { 128 | "locked": { 129 | "lastModified": 1638122382, 130 | "narHash": "sha256-sQzZzAbvKEqN9s0bzWuYmRaA03v40gaJ4+iL1LXjaeI=", 131 | "owner": "numtide", 132 | "repo": "flake-utils", 133 | "rev": "74f7e4319258e287b0f9cb95426c9853b282730b", 134 | "type": "github" 135 | }, 136 | "original": { 137 | "owner": "numtide", 138 | "repo": "flake-utils", 139 | "type": "github" 140 | } 141 | }, 142 | "forest": { 143 | "inputs": { 144 | "bunnycdn-cli": "bunnycdn-cli", 145 | "flake-utils": "flake-utils_2", 146 | "forest-server": "forest-server", 147 | "forester": "forester", 148 | "nixpkgs": "nixpkgs_5", 149 | "systems": "systems_6" 150 | }, 151 | "locked": { 152 | "lastModified": 1716328969, 153 | "narHash": "sha256-f1aicY0bxnFML2hYmy7yPxy4e1fZQr3UZXCrM6aexYs=", 154 | "owner": "LocalCharts", 155 | "repo": "forest", 156 | "rev": "7b28cdd9d8390ad2a1f10994a4a4fc6b3bd14a71", 157 | "type": "github" 158 | }, 159 | "original": { 160 | "owner": "LocalCharts", 161 | "repo": "forest", 162 | "type": "github" 163 | } 164 | }, 165 | "forest-server": { 166 | "inputs": { 167 | "flake-utils": "flake-utils_3", 168 | "nixpkgs": "nixpkgs_2", 169 | "rust-overlay": "rust-overlay" 170 | }, 171 | "locked": { 172 | "lastModified": 1702852235, 173 | "narHash": "sha256-ZcQkBGY5xeUX06k9ulT8pskkWJBhTl67YL1J3rTkf68=", 174 | "owner": "kentookura", 175 | "repo": "forest-server", 176 | "rev": "52b06897956cfaafcf97b349359fe297574e36e2", 177 | "type": "github" 178 | }, 179 | "original": { 180 | "owner": "kentookura", 181 | "repo": "forest-server", 182 | "type": "github" 183 | } 184 | }, 185 | "forester": { 186 | "inputs": { 187 | "flake-utils": "flake-utils_5", 188 | "nixpkgs": [ 189 | "forest", 190 | "forester", 191 | "opam-nix", 192 | "nixpkgs" 193 | ], 194 | "opam-nix": "opam-nix" 195 | }, 196 | "locked": { 197 | "lastModified": 1707823936, 198 | "narHash": "sha256-5jgib/rifKUfhJMXpU1VM8g+4bYB2HvvN+1xuBQH4RY=", 199 | "owner": "~jonsterling", 200 | "repo": "ocaml-forester", 201 | "rev": "f96d8226e3c7e10706768bf9f91529092dd5c8ca", 202 | "type": "sourcehut" 203 | }, 204 | "original": { 205 | "owner": "~jonsterling", 206 | "repo": "ocaml-forester", 207 | "type": "sourcehut" 208 | } 209 | }, 210 | "mirage-opam-overlays": { 211 | "flake": false, 212 | "locked": { 213 | "lastModified": 1661959605, 214 | "narHash": "sha256-CPTuhYML3F4J58flfp3ZbMNhkRkVFKmBEYBZY5tnQwA=", 215 | "owner": "dune-universe", 216 | "repo": "mirage-opam-overlays", 217 | "rev": "05f1c1823d891ce4d8adab91f5db3ac51d86dc0b", 218 | "type": "github" 219 | }, 220 | "original": { 221 | "owner": "dune-universe", 222 | "repo": "mirage-opam-overlays", 223 | "type": "github" 224 | } 225 | }, 226 | "napalm": { 227 | "inputs": { 228 | "flake-utils": "flake-utils", 229 | "nixpkgs": [ 230 | "forest", 231 | "bunnycdn-cli", 232 | "nixpkgs" 233 | ] 234 | }, 235 | "locked": { 236 | "lastModified": 1703102458, 237 | "narHash": "sha256-3pOV731qi34Q2G8e2SqjUXqnftuFrbcq+NdagEZXISo=", 238 | "owner": "nix-community", 239 | "repo": "napalm", 240 | "rev": "edcb26c266ca37c9521f6a97f33234633cbec186", 241 | "type": "github" 242 | }, 243 | "original": { 244 | "owner": "nix-community", 245 | "repo": "napalm", 246 | "type": "github" 247 | } 248 | }, 249 | "nixpkgs": { 250 | "locked": { 251 | "lastModified": 1709200309, 252 | "narHash": "sha256-lKdtMbhnBNU1lr978T+wEYet3sfIXXgyiDZNEgx8CV8=", 253 | "owner": "NixOS", 254 | "repo": "nixpkgs", 255 | "rev": "ebe6e807793e7c9cc59cf81225fdee1a03413811", 256 | "type": "github" 257 | }, 258 | "original": { 259 | "owner": "NixOS", 260 | "ref": "nixpkgs-unstable", 261 | "repo": "nixpkgs", 262 | "type": "github" 263 | } 264 | }, 265 | "nixpkgs_2": { 266 | "locked": { 267 | "lastModified": 1701436327, 268 | "narHash": "sha256-tRHbnoNI8SIM5O5xuxOmtSLnswEByzmnQcGGyNRjxsE=", 269 | "path": "/nix/store/7adgvk5zdfq4pwrhsm3n9lzypb12gw0g-source", 270 | "rev": "91050ea1e57e50388fa87a3302ba12d188ef723a", 271 | "type": "path" 272 | }, 273 | "original": { 274 | "id": "nixpkgs", 275 | "type": "indirect" 276 | } 277 | }, 278 | "nixpkgs_3": { 279 | "locked": { 280 | "lastModified": 1681358109, 281 | "narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=", 282 | "owner": "NixOS", 283 | "repo": "nixpkgs", 284 | "rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9", 285 | "type": "github" 286 | }, 287 | "original": { 288 | "owner": "NixOS", 289 | "ref": "nixpkgs-unstable", 290 | "repo": "nixpkgs", 291 | "type": "github" 292 | } 293 | }, 294 | "nixpkgs_4": { 295 | "locked": { 296 | "lastModified": 1682362401, 297 | "narHash": "sha256-/UMUHtF2CyYNl4b60Z2y4wwTTdIWGKhj9H301EDcT9M=", 298 | "owner": "nixos", 299 | "repo": "nixpkgs", 300 | "rev": "884ac294018409e0d1adc0cae185439a44bd6b0b", 301 | "type": "github" 302 | }, 303 | "original": { 304 | "owner": "nixos", 305 | "ref": "nixos-unstable", 306 | "repo": "nixpkgs", 307 | "type": "github" 308 | } 309 | }, 310 | "nixpkgs_5": { 311 | "locked": { 312 | "lastModified": 1701282334, 313 | "narHash": "sha256-MxCVrXY6v4QmfTwIysjjaX0XUhqBbxTWWB4HXtDYsdk=", 314 | "owner": "NixOS", 315 | "repo": "nixpkgs", 316 | "rev": "057f9aecfb71c4437d2b27d3323df7f93c010b7e", 317 | "type": "github" 318 | }, 319 | "original": { 320 | "owner": "NixOS", 321 | "ref": "23.11", 322 | "repo": "nixpkgs", 323 | "type": "github" 324 | } 325 | }, 326 | "nixpkgs_6": { 327 | "locked": { 328 | "lastModified": 1712608508, 329 | "narHash": "sha256-vMZ5603yU0wxgyQeHJryOI+O61yrX2AHwY6LOFyV1gM=", 330 | "path": "/nix/store/450afzqlzzgw6wnyc3dwysf3i5yxyqkr-source", 331 | "rev": "4cba8b53da471aea2ab2b0c1f30a81e7c451f4b6", 332 | "type": "path" 333 | }, 334 | "original": { 335 | "id": "nixpkgs", 336 | "type": "indirect" 337 | } 338 | }, 339 | "opam-nix": { 340 | "inputs": { 341 | "flake-compat": "flake-compat", 342 | "flake-utils": "flake-utils_6", 343 | "mirage-opam-overlays": "mirage-opam-overlays", 344 | "nixpkgs": "nixpkgs_4", 345 | "opam-overlays": "opam-overlays", 346 | "opam-repository": "opam-repository", 347 | "opam2json": "opam2json" 348 | }, 349 | "locked": { 350 | "lastModified": 1706878465, 351 | "narHash": "sha256-0k0KSkU7epRQshZZKsOpyE79lnwn/0q2VagzDhIeZpE=", 352 | "owner": "tweag", 353 | "repo": "opam-nix", 354 | "rev": "9f03f7e0664c369f25e614d3f3be74ea78b647fa", 355 | "type": "github" 356 | }, 357 | "original": { 358 | "owner": "tweag", 359 | "repo": "opam-nix", 360 | "type": "github" 361 | } 362 | }, 363 | "opam-overlays": { 364 | "flake": false, 365 | "locked": { 366 | "lastModified": 1654162756, 367 | "narHash": "sha256-RV68fUK+O3zTx61iiHIoS0LvIk0E4voMp+0SwRg6G6c=", 368 | "owner": "dune-universe", 369 | "repo": "opam-overlays", 370 | "rev": "c8f6ef0fc5272f254df4a971a47de7848cc1c8a4", 371 | "type": "github" 372 | }, 373 | "original": { 374 | "owner": "dune-universe", 375 | "repo": "opam-overlays", 376 | "type": "github" 377 | } 378 | }, 379 | "opam-repository": { 380 | "flake": false, 381 | "locked": { 382 | "lastModified": 1705008664, 383 | "narHash": "sha256-TTjTal49QK2U0yVOmw6rJhTGYM7tnj3Kv9DiEEiLt7E=", 384 | "owner": "ocaml", 385 | "repo": "opam-repository", 386 | "rev": "fa77046c6497f8ca32926acdb7eb1e61777d4c17", 387 | "type": "github" 388 | }, 389 | "original": { 390 | "owner": "ocaml", 391 | "repo": "opam-repository", 392 | "type": "github" 393 | } 394 | }, 395 | "opam2json": { 396 | "inputs": { 397 | "nixpkgs": [ 398 | "forest", 399 | "forester", 400 | "opam-nix", 401 | "nixpkgs" 402 | ] 403 | }, 404 | "locked": { 405 | "lastModified": 1671540003, 406 | "narHash": "sha256-5pXfbUfpVABtKbii6aaI2EdAZTjHJ2QntEf0QD2O5AM=", 407 | "owner": "tweag", 408 | "repo": "opam2json", 409 | "rev": "819d291ea95e271b0e6027679de6abb4d4f7f680", 410 | "type": "github" 411 | }, 412 | "original": { 413 | "owner": "tweag", 414 | "repo": "opam2json", 415 | "type": "github" 416 | } 417 | }, 418 | "root": { 419 | "inputs": { 420 | "forest": "forest", 421 | "nixpkgs": "nixpkgs_6", 422 | "systems": "systems_7" 423 | } 424 | }, 425 | "rust-overlay": { 426 | "inputs": { 427 | "flake-utils": "flake-utils_4", 428 | "nixpkgs": "nixpkgs_3" 429 | }, 430 | "locked": { 431 | "lastModified": 1701742626, 432 | "narHash": "sha256-ASuWURoeuV7xKZEVSCJsdHidrgprJexNkFWU/cfZ5LE=", 433 | "owner": "oxalica", 434 | "repo": "rust-overlay", 435 | "rev": "1f48c08cae1b2c4d5f201a77abfe31fc3b95a4cf", 436 | "type": "github" 437 | }, 438 | "original": { 439 | "owner": "oxalica", 440 | "repo": "rust-overlay", 441 | "type": "github" 442 | } 443 | }, 444 | "systems": { 445 | "locked": { 446 | "lastModified": 1681028828, 447 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 448 | "owner": "nix-systems", 449 | "repo": "default", 450 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 451 | "type": "github" 452 | }, 453 | "original": { 454 | "owner": "nix-systems", 455 | "repo": "default", 456 | "type": "github" 457 | } 458 | }, 459 | "systems_2": { 460 | "locked": { 461 | "lastModified": 1681028828, 462 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 463 | "owner": "nix-systems", 464 | "repo": "default", 465 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 466 | "type": "github" 467 | }, 468 | "original": { 469 | "owner": "nix-systems", 470 | "repo": "default", 471 | "type": "github" 472 | } 473 | }, 474 | "systems_3": { 475 | "locked": { 476 | "lastModified": 1681028828, 477 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 478 | "owner": "nix-systems", 479 | "repo": "default", 480 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 481 | "type": "github" 482 | }, 483 | "original": { 484 | "owner": "nix-systems", 485 | "repo": "default", 486 | "type": "github" 487 | } 488 | }, 489 | "systems_4": { 490 | "locked": { 491 | "lastModified": 1681028828, 492 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 493 | "owner": "nix-systems", 494 | "repo": "default", 495 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 496 | "type": "github" 497 | }, 498 | "original": { 499 | "owner": "nix-systems", 500 | "repo": "default", 501 | "type": "github" 502 | } 503 | }, 504 | "systems_5": { 505 | "locked": { 506 | "lastModified": 1681028828, 507 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 508 | "owner": "nix-systems", 509 | "repo": "default", 510 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 511 | "type": "github" 512 | }, 513 | "original": { 514 | "owner": "nix-systems", 515 | "repo": "default", 516 | "type": "github" 517 | } 518 | }, 519 | "systems_6": { 520 | "locked": { 521 | "lastModified": 1681028828, 522 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 523 | "owner": "nix-systems", 524 | "repo": "default", 525 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 526 | "type": "github" 527 | }, 528 | "original": { 529 | "owner": "nix-systems", 530 | "repo": "default", 531 | "type": "github" 532 | } 533 | }, 534 | "systems_7": { 535 | "locked": { 536 | "lastModified": 1681028828, 537 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 538 | "owner": "nix-systems", 539 | "repo": "default", 540 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 541 | "type": "github" 542 | }, 543 | "original": { 544 | "owner": "nix-systems", 545 | "repo": "default", 546 | "type": "github" 547 | } 548 | } 549 | }, 550 | "root": "root", 551 | "version": 7 552 | } 553 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | systems.url = "github:nix-systems/default"; 4 | forest.url = "github:LocalCharts/forest"; 5 | }; 6 | 7 | outputs = {systems, nixpkgs, forest, ...} @ inputs: let 8 | eachSystem = f: 9 | nixpkgs.lib.genAttrs (import systems) (system: 10 | f nixpkgs.legacyPackages.${system} 11 | ); 12 | in { 13 | packages = eachSystem (pkgs: 14 | let 15 | offlineCache = pkgs.fetchYarnDeps { 16 | yarnLock = ./yarn.lock; 17 | hash = "sha256-DKrTfmQcOIlINvb7bCDCJbJAGr41Otww7YkyG1R8ClM="; 18 | }; 19 | headers = pkgs.fetchurl { 20 | url = "https://nodejs.org/download/release/v20.11.1/node-v20.11.1-headers.tar.gz"; 21 | sha256 = "sha256-CqQskbRB6UX/Q706g3dZxYtDbeV9zQM9AuXLzS+6H4c="; 22 | }; 23 | in { 24 | default = pkgs.mkYarnPackage { 25 | pname = "silviculture"; 26 | version = "0.1.0"; 27 | src = ./.; 28 | packageJSON = ./package.json; 29 | inherit offlineCache; 30 | pkgConfig = { 31 | sqlite3 = { 32 | buildInputs = with pkgs; [ nodePackages.node-gyp pkg-config sqlite python3 ]; 33 | postInstall = '' 34 | node-gyp --tarball ${headers} rebuild 35 | ''; 36 | }; 37 | }; 38 | nativeBuildInputs = with pkgs; [ makeWrapper ]; 39 | buildPhase = '' 40 | runHook preBuild 41 | 42 | export HOME=$(mktemp -d) 43 | yarn --offline build 44 | pushd deps/silviculture 45 | npx tsc -p tsconfig.node.json 46 | popd 47 | 48 | runHook postBuild 49 | ''; 50 | 51 | postInstall = '' 52 | chmod +x $out/bin/silviculture 53 | 54 | substituteInPlace $out/bin/silviculture \ 55 | --replace "/usr/bin/env node" ${pkgs.nodejs}/bin/node 56 | 57 | wrapProgram $out/bin/silviculture \ 58 | --prefix PATH : ${pkgs.lib.makeBinPath [ 59 | forest.packages.${pkgs.stdenv.system}.forester 60 | forest.packages.${pkgs.stdenv.system}.tldist 61 | ]} 62 | ''; 63 | dontStrip = true; 64 | }; 65 | }); 66 | devShells = eachSystem (pkgs: { 67 | default = pkgs.mkShell { 68 | buildInputs = with pkgs; [ 69 | nodejs 70 | node2nix 71 | nodePackages.yarn 72 | nodePackages.typescript 73 | nodePackages.typescript-language-server 74 | zola 75 | ]; 76 | }; 77 | }); 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "silviculture", 3 | "private": true, 4 | "version": "0.2.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "bin": { 12 | "silviculture": "server/index.js" 13 | }, 14 | "dependencies": { 15 | "@codemirror/commands": "^6.5.0", 16 | "@codemirror/state": "^6.4.1", 17 | "@codemirror/view": "^6.26.3", 18 | "@fastify/static": "^7.0.3", 19 | "@fastify/websocket": "^10.0.1", 20 | "@fontsource/inria-sans": "^5.0.13", 21 | "@hocuspocus/extension-sqlite": "^2.12.2", 22 | "@hocuspocus/provider": "^2.12.2", 23 | "@hocuspocus/server": "^2.12.2", 24 | "@replit/codemirror-vim": "^6.2.1", 25 | "@solidjs/router": "^0.13.3", 26 | "@types/ws": "^8.5.10", 27 | "@unocss/reset": "^0.59.4", 28 | "cmdk-solid": "^1.0.1", 29 | "codemirror": "^6.0.1", 30 | "fastify": "^4.26.2", 31 | "katex": "^0.16.10", 32 | "ky": "^1.2.4", 33 | "quiver": "git+https://github.com/olynch/quiver.git#master", 34 | "redis": "^4.6.14", 35 | "solid-js": "^1.8.15", 36 | "solid-ninja-keys": "^0.3.0", 37 | "ws": "^8.17.0", 38 | "y-codemirror.next": "^0.3.3", 39 | "yarn": "^1.22.22", 40 | "yjs": "^13.6.15" 41 | }, 42 | "devDependencies": { 43 | "@iconify-json/simple-icons": "^1.1.102", 44 | "@iconify-json/tabler": "^1.1.110", 45 | "@types/katex": "^0.16.7", 46 | "@types/node": "^20.12.7", 47 | "@unocss/preset-icons": "^0.59.4", 48 | "@unocss/preset-wind": "^0.59.4", 49 | "sass": "^1.77.2", 50 | "ts-standard": "^12.0.2", 51 | "tsx": "^4.7.3", 52 | "typescript": "^5.2.2", 53 | "unocss": "^0.59.4", 54 | "vite": "^5.2.0", 55 | "vite-plugin-solid": "^2.10.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /server/builder.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | import { Hocuspocus } from '@hocuspocus/server' 3 | import { RedisClientType } from 'redis' 4 | import { BuildResult } from '../common/api' 5 | import * as path from 'path' 6 | import sqlite3 from 'sqlite3' 7 | import { writeFile } from 'fs/promises' 8 | 9 | async function buildForest( 10 | forestDir: string, 11 | output: string[], 12 | errors: string[] 13 | ): Promise { 14 | const builder = spawn( 15 | 'forester', 16 | ['build', '--root', 'lc-0001', 'trees/'], 17 | { cwd: forestDir } 18 | ) //shouldn't just inherit to stdio, need to pipe to client 19 | builder.stdout.on('data', data => { 20 | output.push(data) 21 | console.log(data.toString()) 22 | }) 23 | builder.stderr.on('data', data => { 24 | errors.push(data) 25 | console.error(data.toString()) 26 | }) 27 | await new Promise((resolve, reject) => { 28 | builder.on('close', errno => { 29 | if (errno !== 0) { 30 | reject(new Error(`build failed with code ${errno}`)) 31 | } else { 32 | console.log('build succeeded') 33 | resolve({}) 34 | } 35 | }) 36 | builder.on('error', (err) => { 37 | reject(err) 38 | }) 39 | }) 40 | } 41 | 42 | async function saveDirty( 43 | hocuspocus: Hocuspocus, 44 | contentRoot: string, 45 | ) { 46 | await Promise.all([...hocuspocus.documents].map((nd) => { 47 | const [name, doc] = nd 48 | console.log(name) 49 | return writeFile( 50 | path.join(contentRoot, name), doc.getText('content')?.toString() 51 | ) 52 | })) 53 | } 54 | 55 | export async function startBuilder( 56 | hocuspocus: Hocuspocus, 57 | _db: sqlite3.Database, 58 | subscriber: RedisClientType, 59 | writer: RedisClientType, 60 | forestDir: string, 61 | contentRoot: string 62 | ) { 63 | await writer.set('state', 'unbuilt') 64 | subscriber.subscribe('build_requests', async (_message, _channel) => { 65 | const state = await writer.get('state') 66 | if (state != 'building') { 67 | await writer.set('state', 'building') 68 | await writer.publish('build_notifications', 'building') 69 | await saveDirty(hocuspocus, contentRoot) 70 | const errors: string[] = [] 71 | const output: string[] = [] 72 | let res: BuildResult 73 | try { 74 | await buildForest(forestDir, errors, output) 75 | res = { success: true, content: '' } 76 | } catch (err) { 77 | res = { 78 | success: false, 79 | stdout: errors.join('\n'), 80 | stderr: output.join('\n') 81 | } 82 | } 83 | await writer.set('state', 'built') 84 | await writer.set('last_build_result', JSON.stringify(res)) 85 | await writer.publish('build_notifications', 'finished') 86 | } 87 | }) 88 | writer.publish('build_requests', '') 89 | } 90 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Server } from '@hocuspocus/server' 4 | import Fastify from 'fastify' 5 | import websocket from '@fastify/websocket' 6 | import * as path from 'path' 7 | import { SQLiteWithFS } from './persistence.js' 8 | import { startBuilder } from './builder.js' 9 | import { RedisClientType, createClient } from 'redis' 10 | import sqlite3 from 'sqlite3' 11 | import { appendFile, readFile, writeFile } from 'fs/promises' 12 | import * as fastifyStatic from '@fastify/static' 13 | import { NewTreeRequest } from '../common/api.js' 14 | import { spawn } from 'child_process' 15 | 16 | /** 17 | * Build workflow: 18 | * 19 | * - Client requests build 20 | * - All trees that have been marked dirty are saved 21 | * - Forester command run 22 | * - Build result saved in database 23 | * - Preview results updated asynchronously 24 | * 25 | * Preview workflow: 26 | * 27 | * - Subscribe to previews for a tree 28 | * - Check if there has been a build 29 | * - If not: do build 30 | * 31 | * - Any client subscribed to previews for a tree gets 32 | * messages when that tree updates after a build. 33 | */ 34 | 35 | const _client = createClient() 36 | 37 | async function newClient(): Promise { 38 | return await _client 39 | .duplicate() 40 | .on('error', err => console.error(err)) 41 | .connect() as RedisClientType 42 | } 43 | 44 | const fastifyClient = await newClient() 45 | 46 | const app = Fastify({ logger: true }) 47 | 48 | const forestDir = process.env.FOREST_DIR || '/tmp/forest' 49 | const builtRoot = path.join(forestDir, 'output') 50 | const contentRoot = path.join(forestDir, 'trees') 51 | const dbPath = 'state.db' 52 | const db = new sqlite3.Database(dbPath) 53 | 54 | app.register(fastifyStatic, { 55 | root: builtRoot, 56 | prefix: '/built' 57 | }) 58 | 59 | 60 | await app.register(websocket) //vscode is being stupid 61 | 62 | const persistence = new SQLiteWithFS(db, contentRoot) 63 | //change 64 | 65 | const hocuspocus = Server.configure({ 66 | async onConnect() { 67 | console.log('🔮') 68 | }, 69 | 70 | extensions: [persistence], 71 | }) 72 | 73 | startBuilder( 74 | hocuspocus, 75 | db, 76 | await newClient(), 77 | await newClient(), 78 | forestDir, 79 | contentRoot 80 | ) 81 | 82 | app.get('/collaboration', { websocket: true }, (socket, req) => { 83 | hocuspocus.handleConnection(socket, req as any, {}); 84 | }) 85 | 86 | 87 | app.get('/preview/:tree', { websocket: true }, async (socket, req) => { 88 | const subscriber = await newClient() 89 | const getter = await newClient() 90 | const tree: string = (req.params as any).tree 91 | 92 | function sendBuilding() { 93 | socket.send(JSON.stringify({ state: 'building' })) 94 | } 95 | 96 | async function sendBuild(first: boolean) { 97 | const last_build_result = await getter.get('last_build_result') 98 | if (last_build_result != null) { 99 | const res = JSON.parse(last_build_result) 100 | if (res.success) { 101 | let content: string 102 | try { 103 | content = await readFile( 104 | path.join(builtRoot, tree + '.xml'), 105 | { encoding: 'utf8' } 106 | ) 107 | } catch (_e) { 108 | content = '' 109 | } 110 | socket.send(JSON.stringify({ 111 | state: 'finished', 112 | result: { success: true, content } 113 | })) 114 | } else { 115 | socket.send(JSON.stringify({ 116 | state: 'finished', 117 | result: res 118 | })) 119 | } 120 | } else if (first) { 121 | sendBuilding() 122 | } 123 | } 124 | 125 | await sendBuild(true) 126 | 127 | await subscriber.subscribe('build_notifications', async (message, _channel) => { 128 | if (message == 'building') { 129 | sendBuilding() 130 | } else if (message == 'finished') { 131 | sendBuild(false) 132 | } 133 | }) 134 | 135 | socket.on('close', () => subscriber.unsubscribe('build_notifications')) 136 | }) 137 | 138 | app.post('/api/build', async (_req) => { 139 | fastifyClient.publish('build_requests', '') 140 | }) 141 | 142 | app.post('/api/newtree', async (req) => { 143 | let body = req.body as NewTreeRequest 144 | let namespace = body.namespace 145 | const foresterProcess = spawn( 146 | 'forester', 147 | ['new', `--prefix=${namespace}`, '--dest=trees', '--dir=trees'], 148 | { cwd: forestDir } 149 | ) 150 | const output: string[] = [] 151 | foresterProcess.stdout.setEncoding('utf8') 152 | foresterProcess.stdout.on('data', data => { 153 | output.push(data) 154 | }) 155 | await new Promise((resolve, reject) => { 156 | foresterProcess.on('close', errno => { 157 | if (errno !== 0) { 158 | reject(new Error(`new tree failed with code ${errno}`)) 159 | } else { 160 | resolve({}) 161 | } 162 | }) 163 | foresterProcess.on('error', (err) => { 164 | reject(err) 165 | }) 166 | }) 167 | console.log(output) 168 | const name = (output[0].match(/trees\/(.+)\.tree/) as string[])[1] 169 | const lines: string[] = [] 170 | if (body.title !== undefined) { 171 | lines.push(`\\title{${body.title}}`) 172 | } 173 | if (body.taxon !== undefined) { 174 | lines.push(`\\taxon{${body.taxon}}`) 175 | } 176 | await appendFile(path.join(contentRoot, name + '.tree'), lines.join('\n')) 177 | return { name } 178 | }) 179 | 180 | app.listen({ port: 1234 }) 181 | -------------------------------------------------------------------------------- /server/persistence.ts: -------------------------------------------------------------------------------- 1 | import sqlite3 from 'sqlite3' 2 | import * as path from 'path' 3 | import * as Y from 'yjs' 4 | import { readFile, writeFile } from 'fs/promises' 5 | import { Extension, onLoadDocumentPayload, onChangePayload } from '@hocuspocus/server' 6 | 7 | export const schema = `CREATE TABLE IF NOT EXISTS "documents" ( 8 | "name" varchar(255) NOT NULL, 9 | "data" blob NOT NULL, 10 | UNIQUE(name) 11 | )` 12 | 13 | export const selectQuery = ` 14 | SELECT data FROM "documents" where name = $name ORDER BY rowid DESC 15 | ` 16 | 17 | export const upsertQuery = ` 18 | INSERT INTO "documents" ("name", "data") VALUES ($name, $data) 19 | ON CONFLICT(name) DO UPDATE SET data = $data 20 | ` 21 | 22 | export interface SQLiteWithFSConfiguration { 23 | databasePath: string, 24 | contentRoot: string, 25 | schema: string, 26 | } 27 | 28 | export class SQLiteWithFS implements Extension { 29 | db: sqlite3.Database 30 | contentRoot: string 31 | 32 | constructor(db: sqlite3.Database, contentRoot: string) { 33 | this.db = db 34 | this.db.run(schema) 35 | this.contentRoot = contentRoot 36 | } 37 | 38 | async onLoadDocument(data: onLoadDocumentPayload): Promise { 39 | return new Promise((resolve, reject) => { 40 | this.db?.get(selectQuery, { 41 | $name: data.documentName, 42 | }, async (error, row) => { 43 | if (error) { 44 | reject(error) 45 | } else if (typeof row == 'undefined') { 46 | try { 47 | let contents: string 48 | try { 49 | contents = await readFile( 50 | this.getPath(data.documentName), 51 | { encoding: 'utf8' } 52 | ) 53 | } catch (_e) { 54 | contents = '' 55 | } 56 | const doc = data.document 57 | const ycontents = doc.getText('content') 58 | ycontents.insert(0, contents) 59 | resolve(data.document) 60 | } catch (err) { 61 | reject(err) 62 | } 63 | } else { 64 | Y.applyUpdate(data.document, (row as any)?.data) 65 | resolve(data.document) 66 | } 67 | }) 68 | }) 69 | } 70 | 71 | getPath(name: string) { 72 | return path.join(this.contentRoot, name) 73 | } 74 | 75 | async onStoreDocument(data: onChangePayload) { 76 | const name = data.documentName 77 | const doc = data.document 78 | this.db.run(upsertQuery, { 79 | $name: name, 80 | $data: Y.encodeStateAsUpdate(doc) 81 | }) 82 | await writeFile(this.getPath(name), doc.getText('content').toString(), {}) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HocuspocusProvider } from '@hocuspocus/provider' 2 | import { useParams } from '@solidjs/router' 3 | import { Editor } from './Editor' 4 | import { Preview } from './Preview' 5 | import { createSignal, JSXElement, createMemo } from 'solid-js' 6 | import {CommandMenu} from './cmdk' 7 | import ky from 'ky' 8 | import { Pane, PaneState } from './Pane' 9 | /* import { Quiver } from './Quiver' */ 10 | import { TopBar } from './TopBar' 11 | import './styles/anim.css' 12 | 13 | function hasEditor (s: PaneState): boolean { 14 | return (s === PaneState.EDITOR_ONLY || s === PaneState.EDITOR_AND_PREVIEW) 15 | } 16 | 17 | function hasPreview (s: PaneState): boolean { 18 | return (s === PaneState.PREVIEW_ONLY || s === PaneState.EDITOR_AND_PREVIEW) 19 | } 20 | 21 | function App (): JSXElement { 22 | const params = useParams() 23 | const tree = createMemo(() => { 24 | return params.tree.replace(/\.xml$/, '') 25 | }) 26 | // Connect it to the backend 27 | const provider = createMemo((oldProvider: HocuspocusProvider | null) => { 28 | if (oldProvider != null) { 29 | oldProvider.destroy() 30 | } 31 | return new HocuspocusProvider({ 32 | url: '/collaboration', 33 | name: tree() + '.tree' 34 | }) 35 | }, null) 36 | 37 | // Define `tasks` as an Array 38 | 39 | const ytext = createMemo(() => { 40 | return provider().document.getText("content"); 41 | }); 42 | 43 | const [paneState, setPaneState] = createSignal(PaneState.EDITOR_AND_PREVIEW); 44 | const [vimState, setVimState] = createSignal(false); 45 | const [helpState, setHelpState] = createSignal(false); 46 | 47 | function build() { 48 | ky.post("/api/build", { json: { } }) 49 | } 50 | 51 | const editor = createMemo(() => { 52 | const ytextNow = ytext() 53 | const providerNow = provider() 54 | const treeNow = tree() 55 | 56 | return ( 57 | 64 | ) 65 | }) 66 | 67 | //not sure the top-8 positioning will always look good 68 | return ( 69 |
70 |
71 | 80 | 81 |
88 | {hasEditor(paneState()) && ( 89 | 90 | {editor()} 91 | 92 | )} 93 | {paneState() === PaneState.EDITOR_AND_PREVIEW && ( 94 |
95 | )} 96 | {hasPreview(paneState()) && ( 97 | 98 | 99 | 100 | )} 101 |
102 |
103 |
104 | ) 105 | } 106 | 107 | export default App 108 | -------------------------------------------------------------------------------- /src/Button.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, JSXElement, splitProps } from 'solid-js' 2 | 3 | type ButtonProps = { 4 | children: string 5 | } & JSX.HTMLAttributes 6 | 7 | export function Button (props: ButtonProps): JSXElement { 8 | const [, rest] = splitProps(props, ['children']) 9 | return ( 10 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/Editor.tsx: -------------------------------------------------------------------------------- 1 | import * as random from 'lib0/random' 2 | import * as Y from 'yjs' 3 | // @ts-expect-error 4 | import { yCollab } from 'y-codemirror.next' 5 | import { EditorView, basicSetup } from 'codemirror' 6 | import { EditorState, Compartment } from '@codemirror/state' 7 | import { keymap } from '@codemirror/view' 8 | import { HocuspocusProvider } from '@hocuspocus/provider' 9 | import { vim, Vim } from '@replit/codemirror-vim' 10 | import { JSXElement, createEffect, createSignal, onCleanup, onMount } from 'solid-js' 11 | import { NewTreeRequest, NewTreeResponse } from '../common/api' 12 | import ky from 'ky' 13 | 14 | export const usercolors = [ 15 | { color: '#30bced', light: '#30bced33' }, 16 | { color: '#6eeb83', light: '#6eeb8333' }, 17 | { color: '#ffbc42', light: '#ffbc4233' }, 18 | { color: '#ecd444', light: '#ecd44433' }, 19 | { color: '#ee6352', light: '#ee635233' }, 20 | { color: '#9ac2c9', light: '#9ac2c933' }, 21 | { color: '#8acb88', light: '#8acb8833' }, 22 | { color: '#1be7ff', light: '#1be7ff33' } 23 | ] 24 | 25 | // select a random color for this user 26 | export const userColor = usercolors[random.uint32() % usercolors.length] 27 | 28 | interface EditorProps { 29 | ytext: Y.Text 30 | provider: HocuspocusProvider 31 | vibindings : boolean 32 | buildFn: () => void 33 | tree: string 34 | } 35 | 36 | async function newTransclude(req: NewTreeRequest, ev: EditorView) { 37 | const response = (await ( 38 | await ky.post('/api/newtree', { json: req }) 39 | ).json()) as NewTreeResponse 40 | const selection = ev.state.selection.main 41 | const toInsert = `\\transclude{${response.name}}` 42 | ev.dispatch({ 43 | changes: { 44 | from: selection.head, 45 | insert: toInsert 46 | }, 47 | selection: { 48 | anchor: selection.head + toInsert.length 49 | } 50 | }) 51 | } 52 | 53 | type TextInputProps = { 54 | name: string, 55 | focus: boolean, 56 | ref?: (elt: HTMLElement) => void 57 | } 58 | 59 | function capitalize(s: string) { 60 | return s.charAt(0).toUpperCase() + s.slice(1) 61 | } 62 | 63 | function TextInput (props: TextInputProps) { 64 | let ref: HTMLElement 65 | 66 | 67 | if (props.focus) { 68 | onMount(() => ref.focus()) 69 | } 70 | 71 | return ( 72 | <> 73 | 74 | { ref = elt; if (props.ref) { props.ref(elt) } }} class="w-1/2 my-2" name={props.name} type="text"> 75 | 76 | ) 77 | } 78 | 79 | type NewTreeModalProps = { 80 | visible: boolean, 81 | submit: (req: NewTreeRequest) => Promise, 82 | defaultNamespace: string, 83 | cancel: () => void 84 | } 85 | 86 | function undefIfEmpty(s: string | undefined) { 87 | return s == '' ? undefined : s 88 | } 89 | 90 | 91 | function NewTreeModal (props: NewTreeModalProps): JSXElement { 92 | async function onSubmit(evt: SubmitEvent) { 93 | console.log('submitted') 94 | evt.preventDefault() 95 | const form = evt.target as HTMLFormElement 96 | const formData = new FormData(form) 97 | await props.submit({ 98 | namespace: formData.get('namespace')?.toString() as string, 99 | taxon: undefIfEmpty(formData.get('taxon')?.toString()), 100 | title: undefIfEmpty(formData.get('title')?.toString()), 101 | }) 102 | form.reset() 103 | } 104 | 105 | onMount(() => { 106 | const down = (e: KeyboardEvent) => { 107 | if (e.key === 'Escape') props.cancel() 108 | } 109 | 110 | document.addEventListener('keydown', down) 111 | onCleanup(() => document.removeEventListener('keydown', down)) 112 | }) 113 | 114 | 115 | let ref: HTMLElement 116 | let namespaceInput: HTMLElement | undefined 117 | 118 | createEffect(() => { 119 | if (props.visible) { 120 | if (namespaceInput) { 121 | namespaceInput.focus() 122 | } 123 | } 124 | }) 125 | 126 | const clickOutside = (e: MouseEvent) => { 127 | if (ref && !ref.contains(e.target as any)) { 128 | props.cancel() 129 | } 130 | } 131 | 132 | document.addEventListener('click', clickOutside) 133 | 134 | onCleanup(() => { 135 | document.removeEventListener('mousedown', clickOutside); 136 | }); 137 | return ( 138 |
ref = elt} 140 | class="fixed top-5 left-1/2 -translate-x-1/2 bg-white p-4 border border-black border-solid border-2 rounded flex flex-col z-2000" 141 | classList={{invisible: !props.visible}} 142 | > 143 |
New Tree
144 |
145 | namespaceInput = elt} name="namespace" focus={true} /> 146 | 147 | 148 | 149 | 150 |
Hit enter to create new tree
151 |
152 | ) 153 | } 154 | 155 | export function Editor (props: EditorProps): JSXElement { 156 | let ref: HTMLElement 157 | let view: EditorView 158 | 159 | props.provider.awareness?.setLocalStateField('user', { 160 | name: 'Anonymous ' + Math.floor(Math.random() * 100).toString(), 161 | color: userColor.color, 162 | colorLight: userColor.light 163 | }) 164 | 165 | const [newTreeShown, setNewTreeShown] = createSignal(false) 166 | 167 | function onload (elt: HTMLElement): void { 168 | ref = elt 169 | 170 | const ytext = props.ytext 171 | const provider = props.provider 172 | const vimConf = new Compartment 173 | const undoManager = new Y.UndoManager(ytext) 174 | 175 | Vim.defineEx('write', 'w', props.buildFn) 176 | 177 | let state = EditorState.create({ 178 | doc: ytext.toString(), 179 | extensions: [ 180 | keymap.of([ 181 | { 182 | key: "Mod-Enter", 183 | run: _ => {props.buildFn(); return true} 184 | }, 185 | { 186 | key: "Ctrl-s", 187 | run: _ => {props.buildFn(); return true}, 188 | preventDefault: true 189 | }, 190 | { 191 | key: "Ctrl-i", 192 | run: (_ev) => { 193 | setNewTreeShown(true) 194 | return true 195 | }, 196 | preventDefault: true 197 | } 198 | ]), 199 | vimConf.of(vim()), 200 | basicSetup, 201 | EditorView.lineWrapping, 202 | yCollab(ytext, provider.awareness, { undoManager }) 203 | ] 204 | }) 205 | 206 | view = new EditorView({ 207 | state, 208 | parent: ref 209 | }) 210 | 211 | createEffect(() => { 212 | if (props.vibindings) { 213 | view.dispatch({ 214 | effects: vimConf.reconfigure(vim()) 215 | }) 216 | } else { 217 | view.dispatch({ 218 | effects: vimConf.reconfigure([]) 219 | }) 220 | } 221 | }) 222 | } 223 | 224 | 225 | return ( 226 | <> 227 |
230 | { 233 | await newTransclude(req, view) 234 | setNewTreeShown(false) 235 | view.focus() 236 | }} 237 | defaultNamespace='' 238 | cancel={() => { 239 | setNewTreeShown(false) 240 | view.focus() 241 | }} /> 242 | 243 | ) 244 | } 245 | -------------------------------------------------------------------------------- /src/Pane.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, splitProps } from 'solid-js' 2 | 3 | type PaneProps = { 4 | fullWidth: boolean 5 | children: any 6 | } & JSX.HTMLAttributes 7 | 8 | export function Pane(props: PaneProps) { 9 | const [, rest] = splitProps(props, ['fullWidth', 'children']) 10 | return ( 11 |
18 | {props.children} 19 |
20 | ) 21 | } 22 | 23 | export enum PaneState { 24 | EDITOR_AND_PREVIEW, 25 | EDITOR_ONLY, 26 | PREVIEW_ONLY 27 | } -------------------------------------------------------------------------------- /src/Preview.tsx: -------------------------------------------------------------------------------- 1 | import autoRenderMath from 'katex/contrib/auto-render' 2 | import { JSXElement, createMemo, createSignal, createEffect, createResource } from 'solid-js' 3 | import { BuildNotification, BuildResult } from '../common/api' 4 | import './style.css' 5 | import './custom.css' 6 | import ky from 'ky' 7 | 8 | export interface PreviewProps { 9 | tree: string, 10 | showHelp: boolean 11 | } 12 | 13 | type PreviewLoading = { 14 | state: 'loading' 15 | } 16 | 17 | type PreviewBuilding = { 18 | state: 'building' 19 | } 20 | 21 | type PreviewLoaded = { 22 | state: 'loaded', 23 | result: BuildResult 24 | } 25 | 26 | function Help(): JSXElement { 27 | return (
28 |

Important keybindings

29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
Keyboard shortcutEffect
Ctrl-enter, Ctrl-sBuild
Ctrl-k, Ctrl-sQuick jump between trees
Ctrl-iInsert transclude to new tree
37 |

Quick Forester Reference

38 |

Forester uses LaTeX syntax (i.e. \tag[optional argument]{main argument}), but HTML names for common tags.

39 |

For instance, instead of \begin{itemize} \item A \item B \end{itemize}, one would use \ul{\li{A}\li{B}}, mimicking the equivalent HTML <ul><li>A</li><li>B</li></ul>.

40 |

Instead of dollar signs, forester uses #{math} for inline math, and ##{math} for display math.

41 |

The best way to get a feel for Forester is by browsing through some documents

42 |

The second best way is to read some tutorials:

43 | 47 |

About Silviculture

48 | 49 | 50 | 51 | 52 | 58 | 59 | 60 | 61 | 68 | 69 | 70 |
Source: 53 | 54 | 55 | github.com/LocalCharts/silviculture 56 | 57 |
Homepage: 62 | 63 | 64 | forest.localcharts.org/silviculture-0001.xml 65 | 66 | 67 |
71 |

72 | Thanks to the Topos Institute and GoodForever for supporting development of Silviculture. 73 |

74 |
75 | ) 76 | } 77 | 78 | type PreviewState = PreviewLoading | PreviewLoaded | PreviewBuilding 79 | 80 | export function Preview (props: PreviewProps): JSXElement { 81 | const parser = new DOMParser() 82 | 83 | const [processor, {}] = createResource(async () => { 84 | const style = await (await ky.get('/built/forest.xsl')).text() 85 | const xsl = parser.parseFromString(style, 'application/xml') 86 | const processor = new XSLTProcessor() 87 | processor.importStylesheet(xsl) 88 | return processor 89 | }) 90 | 91 | const [getState, setState] = createSignal({ state: 'loading' }) 92 | 93 | createEffect((oldSocket: WebSocket | null) => { 94 | if (oldSocket) { 95 | oldSocket.close() 96 | } 97 | const socket = new WebSocket(`/preview/${props.tree}`) 98 | socket.onmessage = (ev: MessageEvent) => { 99 | const message = JSON.parse(ev.data) as BuildNotification 100 | if (message.state == 'building') { 101 | setState({ state: 'building' }) 102 | } else if (message.state == 'finished') { 103 | setState({ 104 | state: 'loaded', 105 | result: message.result 106 | }) 107 | } 108 | } 109 | return socket 110 | }, null) 111 | 112 | const content = createMemo(() => { 113 | const state = getState() 114 | if (props.showHelp) { 115 | return Help() 116 | } 117 | if (state.state == 'loaded') { 118 | if (state.result.success) { 119 | const p = processor() 120 | if (!p) { 121 | return 122 | } 123 | const transformed = parser.parseFromString( 124 | state.result.content, 125 | 'application/xml' 126 | ) 127 | const doc = p.transformToDocument(transformed) 128 | const content = doc.getElementById('grid-wrapper') as HTMLElement 129 | autoRenderMath(content) 130 | return content 131 | } else { 132 | return ( 133 | <> 134 |
135 |               {state.result.stderr}
136 |             
137 |
138 |               {state.result.stdout}
139 |             
140 | 141 | ) 142 | } 143 | } else if (state.state == 'building') { 144 | return ( 145 |
146 | {"building".split("").map((char, index) => ( 147 | {char} 148 | ))} 149 |
150 | ) 151 | } else { 152 | return ( 153 |
154 | {"loading".split("").map((char, index) => ( 155 | {char} 156 | ))} 157 |
158 | ) 159 | } 160 | }) 161 | 162 | return ( 163 |
164 | {content()} 165 |
166 | ) 167 | } 168 | -------------------------------------------------------------------------------- /src/Quiver.tsx: -------------------------------------------------------------------------------- 1 | import { UI } from "./quiver/ui.js" 2 | import { DOM } from "./quiver/dom.js" 3 | import { onMount } from "solid-js" 4 | import "./quiver/main.css" 5 | 6 | export function Quiver () { 7 | let elt: Element 8 | 9 | onMount(() => { 10 | const body = new DOM.Element(elt) 11 | const ui = new UI(body) 12 | ui.initialise() 13 | }) 14 | 15 | return ( 16 |
17 |
18 |
elt = e}>
19 |
20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import { JSX, JSXElement, splitProps } from 'solid-js' 2 | import { PaneState } from './Pane' 3 | import ky from 'ky' 4 | import { NewTreeResponse } from '../common/api' 5 | export { TopBar } 6 | 7 | 8 | type TopBarChoiceProps = { 9 | enabled: boolean 10 | } & JSX.HTMLAttributes 11 | 12 | //split into topbar.tsx? 13 | function TopBarChoice (props: TopBarChoiceProps): JSXElement { 14 | const [, rest] = splitProps(props, ['enabled']) 15 | return ( 16 | 28 | ) 29 | } 30 | 31 | interface TopBarProps { 32 | state: PaneState 33 | vimstate: boolean 34 | helpState: boolean 35 | setHelpState: (b: boolean) => void 36 | setState: (s: PaneState) => void 37 | setVimState: (s: boolean) => void 38 | buildFunction : () => void 39 | } 40 | 41 | function TopBar (props: TopBarProps): JSXElement { 42 | return ( 43 |
44 | props.setState(PaneState.EDITOR_ONLY)} 47 | > 48 |
49 | 50 | props.setState(PaneState.EDITOR_AND_PREVIEW)} 53 | > 54 |
55 |
56 | 57 | props.setState(PaneState.PREVIEW_ONLY)} 60 | > 61 |
62 | 63 |
64 | props.setVimState(!props.vimstate)} 67 | > 68 |
69 |
70 | props.buildFunction()} 73 | > 74 |
build
75 |
76 | { 79 | props.setHelpState(!props.helpState) 80 | if (props.state === PaneState.EDITOR_ONLY) { 81 | props.setState(PaneState.EDITOR_AND_PREVIEW) 82 | } 83 | }} 84 | > 85 |
help
86 |
87 |
88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/YSignal.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs' 2 | import { createSignal } from 'solid-js' 3 | 4 | /** 5 | * Produces a solidjs signal from a Y.Array that tracks updates. 6 | * 7 | * Note that this does not export the `setArray` part of this signal; 8 | * this is on purpose; the only way to update the signal should be 9 | * through modifying the original Y.Array. 10 | */ 11 | export function yArraySignal (yArray: Y.Array): (() => T[]) { 12 | const [array, setArray] = createSignal(yArray.toArray()) 13 | yArray.observe((_evt, _txn) => { 14 | setArray(yArray.toArray()) 15 | }) 16 | return array 17 | } 18 | -------------------------------------------------------------------------------- /src/cmdk.css: -------------------------------------------------------------------------------- 1 | [data-selected="true"] { 2 | border-left-color: black; 3 | } 4 | -------------------------------------------------------------------------------- /src/cmdk.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from 'cmdk-solid' 2 | import { createSignal, onMount, onCleanup, Show, createResource, Resource, For } from 'solid-js' 3 | import { useNavigate } from '@solidjs/router' 4 | import ky from 'ky'; 5 | export { CommandMenu }; 6 | import './cmdk.css' 7 | 8 | type TreeItemProps = { 9 | tree: Tree, 10 | name: string 11 | goto: (tree: string) => void 12 | } 13 | 14 | function TreeItem(props: TreeItemProps) { 15 | const tree = props.tree 16 | let title: string 17 | 18 | if (tree.taxon == null && tree.title == null) { 19 | title = 'Untitled' 20 | } else if (tree.taxon == null) { 21 | title = tree.title! 22 | } else if (tree.title == null) { 23 | title = tree.taxon 24 | } else { 25 | title = `${tree.taxon}. ${tree.title}` 26 | } 27 | 28 | return ( 29 | props.goto(props.name)} 31 | class="p-4 bg-gray-50 my-2 cursor-pointer text-sm border-none border-l-solid border-l-2 border-l-transparent rounded-sm"> 32 | {`${title} [${props.name}]`} 33 | 34 | ) 35 | } 36 | 37 | type Tree = { 38 | title: string | null, 39 | route: string, 40 | taxon: string | null, 41 | tags: string[], 42 | } 43 | 44 | type Trees = Record 45 | 46 | type CommandMenuProps = { 47 | buildFunction: () => void, 48 | } 49 | 50 | const CommandMenu = (_props: CommandMenuProps) => { 51 | const [open, setOpen] = createSignal(false) 52 | 53 | const [trees, {}] = createResource(async () => { 54 | return await (await ky.get('/built/forest.json')).json() 55 | }) 56 | 57 | let menuRef: HTMLElement 58 | // Toggle the menu when ⌘K is pressed 59 | onMount(() => { 60 | const down = (e: KeyboardEvent) => { 61 | if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { 62 | e.preventDefault() 63 | setOpen((open) => !open) 64 | } 65 | if (e.key === 'Escape') setOpen(false) 66 | } 67 | 68 | document.addEventListener('keydown', down) 69 | onCleanup(() => document.removeEventListener('keydown', down)) 70 | }) 71 | 72 | const clickOutside = (e: MouseEvent) => { 73 | if (menuRef && !menuRef.contains(e.target as any)) { 74 | setOpen(false); 75 | } 76 | } 77 | 78 | document.addEventListener('click', clickOutside) 79 | 80 | onCleanup(() => { 81 | document.removeEventListener('mousedown', clickOutside); 82 | }); 83 | 84 | return ( 85 | 86 |
menuRef = el}> 87 | setOpen(false)} /> 90 |
91 |
92 | ); 93 | } 94 | 95 | type CommandInnerProps = { 96 | done: () => void, 97 | trees: Resource 98 | } 99 | 100 | function CommandInner(props: CommandInnerProps) { 101 | const navigate = useNavigate() 102 | 103 | function goto(name: string) { 104 | navigate('/' + name) 105 | props.done() 106 | } 107 | 108 | let ref: HTMLElement 109 | onMount(() => { 110 | ref.focus() 111 | }) 112 | return { 116 | if (value.toLowerCase().includes(search.toLowerCase())) return 1 117 | return 0 118 | }}> 119 | ref = el} placeholder="search for a tree" /> 122 | 123 | No results found. 124 | 125 | {trees => 126 | a[0].localeCompare(b[0]) 128 | )}> 129 | {nt => } 130 | 131 | } 132 | 133 | 134 | 135 | } 136 | 137 | -------------------------------------------------------------------------------- /src/custom.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | margin-top: auto; 3 | padding-top: 0.5rem; 4 | padding-bottom: 0.5rem; 5 | font-size: 0.8em; 6 | } 7 | 8 | span.nlab > span > a { 9 | color: #226622; 10 | } 11 | 12 | span.wikipedia > span > a { 13 | color: #3366cc; 14 | } 15 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Silviculture 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from 'solid-js/web' 3 | import { Router, Route } from '@solidjs/router' 4 | import 'virtual:uno.css' 5 | 6 | import App from './App' 7 | 8 | const root = document.getElementById('root') 9 | 10 | 11 | render( 12 | () => 13 | 14 | 15 | , 16 | root!) 17 | -------------------------------------------------------------------------------- /src/public/icons/about.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/centre-view.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/deselect-all.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/flip-hor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/flip-ver.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/hide-grid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/pullback-checked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/pullback-unchecked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/redo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/reset-zoom.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/rotate.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/save.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/select-all.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/shortcuts.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/show-hints.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/show-queue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/transform.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/public/icons/undo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/var-pullback-checked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/var-pullback-unchecked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/zoom-in.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/public/icons/zoom-out.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/quiver/curve.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { Point, mod } from "./ds.js" 4 | 5 | /// A very small value we use to determine fuzzy equality of points. Floating-point arithmetic is 6 | /// imprecise, so we have to take slight inequalities into account when computing. 7 | export const EPSILON = 10 ** -6; 8 | const INV_EPSILON = 1 / EPSILON; 9 | 10 | // Round a number to the nearest `EPSILON` to avoid floating point precision issues. 11 | function round_to_epsilon(x) { 12 | return Math.round(x * INV_EPSILON) / INV_EPSILON; 13 | } 14 | 15 | export class Curve { 16 | /// Returns whether a point lies inside a polygon. This does so by calculating the winding 17 | /// number for the polygon with respect to the point. If the winding number is nonzero, then 18 | /// the point lies inside the polygon. 19 | /// This algorithm is based on the one at: http://geomalgorithms.com/a03-_inclusion.html. 20 | static point_inside_polygon(point, points) { 21 | // The displacement of a point from a line (calculated via the determinant of a 2x2 matrix). 22 | const displ = ([base, end], point) => { 23 | end = end.sub(base); 24 | point = point.sub(base); 25 | return end.x * point.y - end.y * point.x; 26 | }; 27 | 28 | const wn = [...Array(points.length).keys()].map((i) => { 29 | if ((points[i].y <= point.y) !== (points[(i + 1) % 4].y <= point.y)) { 30 | const d = displ([points[i], points[(i + 1) % 4]], point); 31 | if (d > 0.0) return 1; 32 | if (d < 0.0) return -1; 33 | } 34 | return 0; 35 | }).reduce((a, b) => a + b, 0); 36 | 37 | return wn !== 0; 38 | } 39 | 40 | /// Adds an intersection point to the set. This function round intersection points to `EPSILON`, 41 | /// so that we don't unnecessary add points that are essentially equal. 42 | static add_intersection(intersections, p) { 43 | intersections.add(new Point(round_to_epsilon(p.x), round_to_epsilon(p.y))); 44 | }; 45 | 46 | /// Handle the case when a rectangle entirely contains the curve whilst checking for 47 | /// intersections. 48 | static check_for_containment(origin, rect, permit_containment) { 49 | // We use a version of the rectangle without rounded corners to simplify checking. 50 | const sharp_rect = new RoundedRectangle(rect.centre, rect.size, 0); 51 | if (Curve.point_inside_polygon(origin, sharp_rect.points())) { 52 | if (permit_containment) { 53 | // If the rounded rectangle completely contains the curve, return the 54 | // centre point, to indicate there is an overlap. 55 | return [new CurvePoint(rect.centre, 0, 0)]; 56 | } else { 57 | // We expect an intersection, so the caller should be alerted if this is not the 58 | // case. 59 | throw new Error("Curve was entirely contained by rounded rectangle."); 60 | } 61 | } 62 | // No intersection points were found. 63 | return []; 64 | } 65 | } 66 | 67 | /// A flat symmetric quadratic Bézier curve. 68 | export class Bezier extends Curve { 69 | constructor(origin, w, h, angle) { 70 | super(); 71 | this.origin = origin; 72 | [this.w, this.h] = [w, h]; 73 | this.angle = angle; 74 | 75 | // Computed properties. 76 | this.end = this.origin.add(new Point(this.w, 0)); 77 | // The control point. 78 | this.control = this.origin.add(new Point(this.w / 2, this.h)); 79 | } 80 | 81 | /// Returns the (x, y)-point at t = `t`. This does not take `angle` into account. 82 | point(t) { 83 | return this.origin.lerp(this.control, t).lerp(this.control.lerp(this.end, t), t); 84 | } 85 | 86 | /// Returns the angle of the tangent to the curve at t = `t`. This does not take `angle` into 87 | /// account. 88 | tangent(t) { 89 | return this.control.lerp(this.end, t).sub(this.origin.lerp(this.control, t)).angle(); 90 | } 91 | 92 | /// Returns the Bézier curve from t = 0 to t = `t` as a series of points corresponding 93 | /// to line segments, and their total length. 94 | /// Returns `{ points, length }`. 95 | delineate(t) { 96 | // How many pixels of precision we want for the length. 97 | const EPSILON = 0.25; 98 | 99 | // Start with a single, linear segment. 100 | const points = [[0, this.point(0)], [t, this.point(t)]]; 101 | 102 | let previous_length; 103 | let length = 0; 104 | 105 | do { 106 | // Calculate the current approximation of the arc length. 107 | previous_length = length; 108 | length = 0; 109 | for (let i = 0; i < points.length - 1; ++i) { 110 | length += points[i + 1][1].sub(points[i][1]).length(); 111 | } 112 | } while (length - previous_length > EPSILON && (() => { 113 | // If we're still not within the required precision, double the number of segments. 114 | for (let i = 0; i < points.length - 1; ++i) { 115 | const t = (points[i][0] + points[i + 1][0]) / 2; 116 | points.splice(++i, 0, [t, this.point(t)]); 117 | } 118 | return true; 119 | })()); 120 | 121 | return { points, length }; 122 | } 123 | 124 | /// Returns the arc length of the Bézier curve from t = 0 to t = `t`. 125 | /// These Bézier curves are symmetric, so t = `t` to t = 1 can be calculated by inverting the 126 | /// arc length from t = 0. 127 | arc_length(t) { 128 | const { length } = this.delineate(t); 129 | return length; 130 | } 131 | 132 | /// Returns a function giving the parameter t of the point a given length along the arc of the 133 | /// Bézier curve. (It returns a function, rather than the t for a length, to allow the segments 134 | /// to be cached for efficiency). The returned function does little error-checking, so the 135 | /// caller is responsible for ensuring it is passed only lengths between 0 and the arc length of 136 | /// the curve. 137 | /// If `clamp` is true, we clamp any `t`s less than 0 or greater than 1. Otherwise, we throw an 138 | /// error. 139 | t_after_length(clamp = false) { 140 | const { points } = this.delineate(1); 141 | return (length) => { 142 | // Special-case 0, to avoid NaN below. 143 | if (length === 0) { 144 | return 0; 145 | } 146 | if (length < 0) { 147 | if (clamp) { 148 | return 0; 149 | } else { 150 | throw new Error("Length was less than 0."); 151 | } 152 | } 153 | let distance = 0; 154 | for (let i = 0; i < points.length - 1; ++ i) { 155 | const segment_length = points[i + 1][1].sub(points[i][1]).length(); 156 | if (distance + segment_length >= length) { 157 | // Lerp the t parameter. 158 | return points[i][0] 159 | + (points[i + 1][0] - points[i][0]) * (length - distance) / segment_length; 160 | } 161 | distance += segment_length; 162 | } 163 | if (clamp) { 164 | return 1; 165 | } else { 166 | throw new Error("Length was greater than the arc length."); 167 | } 168 | }; 169 | } 170 | 171 | get height() { 172 | return this.h / 2; 173 | } 174 | 175 | get width() { 176 | return this.w; 177 | } 178 | 179 | /// Intersect the Bézier curve with the given rounded rectangle. Note that the general 180 | /// (analytic) problem of intersecting a Bézier curve with a circle (for the rounded corners) is 181 | /// very difficult, so we approximate circles with regular polygons. If the rounded rectangle 182 | /// entirely contains the Bézier curve, and `permit_containment` is true, a single intersection 183 | /// point (the centre of the rectangle) is returned; otherwise, an error is thrown. 184 | intersections_with_rounded_rectangle(rect, permit_containment) { 185 | // There is one edge case in the following computations, which occurs when the height of the 186 | // Bézier curve is zero (i.e. the curve is a straight line). We special-case this type of 187 | // curve, and do not normalise its height. 188 | const h = this.h || 1; 189 | 190 | // Normalise all the points with respect to the Bézier curve. From this point on, we do 191 | // all computations with respect to `NormalisedBezier` for simplicity. 192 | const points = rect.points().map((p) => { 193 | // Translate the point with respect to the origin. 194 | p = p.sub(this.origin); 195 | // Rotate -θ around the origin. 196 | p = p.rotate(-this.angle); 197 | // Scale the point horizontally and vertically. 198 | p = p.inv_scale(this.w, h); 199 | return p; 200 | }); 201 | 202 | const intersections = new Set(); 203 | 204 | // Calculate the `m` and `c` in `y = m x + c`, given two points on the line. 205 | const m_c = (endpoints) => { 206 | const m = (endpoints[1].y - endpoints[0].y) / (endpoints[1].x - endpoints[0].x); 207 | return { m, c: endpoints[0].y - m * endpoints[0].x }; 208 | }; 209 | 210 | if (this.h === 0) { 211 | // Special-case a straight line, as we can't normalise with respect to this curve. 212 | // This means we're trying to intersect `rect` with a horizontal line (0, 0) to (1, 0). 213 | for (let i = 0; i < points.length; ++i) { 214 | const endpoints = [points[i], points[(i + 1) % points.length]]; 215 | if (Math.abs(endpoints[0].x - endpoints[1].x) <= EPSILON) { 216 | // `x = a`. 217 | if ( 218 | endpoints[0].x >= 0 && endpoints[0].x <= 1 219 | && Math.min(endpoints[0].y, endpoints[1].y) <= 0 220 | && Math.max(endpoints[0].y, endpoints[1].y) >= 0 221 | ) { 222 | Curve.add_intersection(intersections, new Point(endpoints[0].x, 0)); 223 | } 224 | } else { 225 | // `y = m x + c`. 226 | const { m, c } = m_c(endpoints); 227 | if (Math.abs(m) > EPSILON) { 228 | // The line is diagonal and will thus intersect the rectangle at at most one 229 | // point. 230 | const x = -c / m; 231 | if ( 232 | x >= 0 && x <= 1 233 | && x >= Math.min(endpoints[0].x, endpoints[1].x) - EPSILON 234 | && x <= Math.max(endpoints[0].x, endpoints[1].x) + EPSILON 235 | ) { 236 | Curve.add_intersection(intersections, new Point(x, 0)); 237 | } 238 | } else if (Math.abs(endpoints[0].y) <= EPSILON) { 239 | // The lines lies along one of the lines making up the rectangle. There are 240 | // thus an infinite number of intersections. In this case, we return just 241 | // those extremal points. 242 | const min = Math.min(endpoints[0].x, endpoints[1].x); 243 | const max = Math.max(endpoints[0].x, endpoints[1].x); 244 | if (min <= 1 && max >= 0) { 245 | Curve.add_intersection(intersections, new Point(Math.max(min, 0), 0)); 246 | Curve.add_intersection(intersections, new Point(Math.min(max, 1), 0)); 247 | } 248 | } 249 | } 250 | } 251 | } else { 252 | // The usual case: when we have a nontrivial Bézier curve. 253 | for (let i = 0; i < points.length; ++i) { 254 | const endpoints = [points[i], points[(i + 1) % points.length]]; 255 | if (Math.abs(endpoints[0].x - endpoints[1].x) <= EPSILON) { 256 | // `x = a`. 257 | const y = NormalisedBezier.y_intersection_with_vertical_line(endpoints[0].x); 258 | if ( 259 | y >= 0 260 | && y >= Math.min(endpoints[0].y, endpoints[1].y) 261 | && y <= Math.max(endpoints[0].y, endpoints[1].y) 262 | ) { 263 | // `y` must be at most `0.5`. 264 | Curve.add_intersection(intersections, new Point(endpoints[0].x, y)); 265 | } 266 | } else { 267 | // `y = m x + c`. 268 | const { m, c } = m_c(endpoints); 269 | NormalisedBezier.x_intersections_with_nonvertical_line(m, c) 270 | .filter((x) => { 271 | return x >= 0 && x <= 1 272 | && x >= Math.min(endpoints[0].x, endpoints[1].x) 273 | && x <= Math.max(endpoints[0].x, endpoints[1].x); 274 | }) 275 | .map((x) => new Point(x, m * x + c)) 276 | .forEach((int) => Curve.add_intersection(intersections, int)); 277 | } 278 | } 279 | } 280 | 281 | // If there are no intersections, check whether the rectangle entirely contains the curve. 282 | if (intersections.size === 0) { 283 | return Curve.check_for_containment(this.origin, rect, permit_containment); 284 | } 285 | 286 | return Array.from(intersections).map((p) => { 287 | // The derivative of the normalised Bézier curve is `2 - 4x`. 288 | return new CurvePoint(p.scale(this.w, h), p.x, Math.atan2((2 - 4 * p.x) * h, this.w)); 289 | }); 290 | } 291 | 292 | /// Render the Bézier curve to an SVG path. 293 | render(path) { 294 | return path.curve_by(new Point(this.w / 2, this.h), new Point(this.w, 0)); 295 | } 296 | } 297 | 298 | /// A point on a quadratic Bézier curve or arc, which also records the parameter `t` and the tangent 299 | /// `angle` of the curve at the point. 300 | export class CurvePoint extends Point { 301 | constructor(point, t, angle) { 302 | super(point.x, point.y); 303 | this.t = t; 304 | this.angle = angle; 305 | } 306 | } 307 | 308 | /// A quadratic Bézier curve whose endpoints are `(0, 0)` and `(1, 0)` and whose control point 309 | /// is `(0.5, 1)`. The highest point on the curve is therefore `(0.5, 0.5)`. The equation of the 310 | /// curve is `y = 2 x (1 - x)`. This makes the mathematics much simpler than dealing with arbitrary 311 | /// Bézier curves all the time. 312 | export class NormalisedBezier { 313 | /// Returns the `x` co-ordinates of the intersection with the line `y = m x + c`. 314 | static x_intersections_with_nonvertical_line(m, c) { 315 | const determinant = m ** 2 - 4 * m + 4 - 8 * c; 316 | if (determinant > 0) { 317 | return [(2 - m + determinant ** 0.5) / 4, (2 - m - determinant ** 0.5) / 4]; 318 | } else if (determinant === 0) { 319 | return [(2 - m + determinant ** 0.5) / 4]; 320 | } else { 321 | return []; 322 | } 323 | } 324 | 325 | /// Returns the `y` co-ordinates of the intersection with the line `x = a`. 326 | static y_intersection_with_vertical_line(a) { 327 | return 2 * a * (1 - a); 328 | } 329 | } 330 | 331 | export class RoundedRectangle { 332 | /// Create a rounded rectangle with centre `(cx, cy)`, width `w`, height `h` and border radius 333 | /// `r`. 334 | constructor(centre, size, radius) { 335 | this.centre = centre; 336 | this.size = size; 337 | this.r = radius; 338 | } 339 | 340 | /// Returns the points forming the rounded rectangle (with an approximation for the rounded 341 | /// corners). The points are returned in clockwise order. 342 | /// `min_segment_length` specifies the precision of the approximation, as the maximum length of 343 | /// any straight line used to approximate a curve. This must be greater than zero. 344 | points(max_segment_length = 5) { 345 | const points = []; 346 | 347 | // The lower bound on the number of sides the corner polygons have, in order to limit the 348 | // maximum length of any side to `max_segment_length`. We use these polygons to approximate 349 | // circles. 350 | const n = this.r !== 0 ? Math.PI / Math.atan(max_segment_length / (2 * this.r)) : 0; 351 | // The actual number of sides the polygons have. 352 | const sides = Math.ceil(n); 353 | // The length from the centre of the polygon to a corner. Note that we want to 354 | // over-approximate circles (i.e. the circles should be contained within the polygons), 355 | // rather than under-approximate them, which is why we use `R` rather than `this.r` for the 356 | // polygon radius. 357 | const R = this.r / Math.cos(Math.PI / sides); 358 | 359 | /// `sx` and `sy` are the signs for the side of the rectangle for which we're drawing 360 | /// a corner. 361 | const add_corner_points = (sx, sy, angle_offset) => { 362 | points.push(this.centre 363 | .add(this.size.div(2).sub(Point.diag(this.r)).scale(sx, sy)) 364 | .add(Point.lendir(this.r, angle_offset)) 365 | ); 366 | for (let i = 0; i < sides / 4; ++i) { 367 | const angle = (i + 0.5) / sides * 2 * Math.PI + angle_offset; 368 | points.push(this.centre 369 | .add(this.size.div(2).sub(Point.diag(this.r)).scale(sx, sy)) 370 | .add(Point.lendir(R, angle)) 371 | ); 372 | } 373 | angle_offset += Math.PI / 2; 374 | points.push(this.centre 375 | .add(this.size.div(2).sub(Point.diag(this.r)).scale(sx, sy)) 376 | .add(Point.lendir(this.r, angle_offset)) 377 | ); 378 | return angle_offset; 379 | } 380 | 381 | let angle_offset = 0; 382 | 383 | // Bottom-right corner. 384 | angle_offset = add_corner_points(1, 1, angle_offset); 385 | 386 | // Bottom-left corner. 387 | angle_offset = add_corner_points(-1, 1, angle_offset); 388 | 389 | // Top-left corner. 390 | angle_offset = add_corner_points(-1, -1, angle_offset); 391 | 392 | // Top-right corner. 393 | angle_offset = add_corner_points(1, -1, angle_offset); 394 | 395 | // Remove zero-length segments. These can occur when the border radius is very small. 396 | for (let i = points.length - 2; i >= 0; --i) { 397 | if (Math.abs(points[i].x - points[i + 1].x) <= EPSILON 398 | && Math.abs(points[i].y - points[i + 1].y) <= EPSILON 399 | ) { 400 | points.splice(i + 1, 1); 401 | } 402 | } 403 | 404 | return points; 405 | } 406 | } 407 | 408 | /// A very simple class for computing the value of a cubic Bézier at a point, using for replicating 409 | /// CSS transition timing functions in JavaScript. 410 | export class CubicBezier { 411 | constructor(p0, p1, p2, p3) { 412 | this.p0 = p0; 413 | this.p1 = p1; 414 | this.p2 = p2; 415 | this.p3 = p3; 416 | } 417 | 418 | point(t) { 419 | const p = this.p0.mul((1 - t) ** 3) 420 | .add(this.p1.mul(3 * (1 - t) ** 2 * t)) 421 | .add(this.p2.mul(3 * (1 - t) * t ** 2)) 422 | .add(this.p3.mul(t ** 3)); 423 | // The caller of this method never needs an angle. 424 | return new CurvePoint(p, t, null); 425 | } 426 | } 427 | 428 | /// A circular arc. 429 | export class Arc extends Curve { 430 | constructor(origin, chord, major, radius, angle) { 431 | super(); 432 | this.origin = origin; 433 | this.chord = chord; 434 | this.major = major; 435 | this.radius = radius; 436 | this.angle = angle; 437 | 438 | // Computed properties. 439 | this.sagitta = this.radius 440 | - Math.sign(this.radius) * (this.radius ** 2 - this.chord ** 2 / 4) ** 0.5; 441 | // The normalised circle centre, not taking into account the origin or angle. 442 | this.centre_normalised = new Point( 443 | this.chord / 2, 444 | (this.radius - this.sagitta) * (this.major ? -1 : 1), 445 | ); 446 | const start_angle = mod(this.centre_normalised.neg().angle(), 2 * Math.PI); 447 | this.sweep_angle = Math.PI + (2 * Math.PI - 2 * start_angle) * this.clockwise, 448 | this.centre = this.origin.add(this.centre_normalised.rotate(this.angle)); 449 | this.start_angle = mod(start_angle + this.angle, 2 * Math.PI); 450 | } 451 | 452 | /// Returns a multiplier depending on whether the radius is nonnegative or not. 453 | get clockwise() { 454 | return this.radius >= 0 ? 1 : -1; 455 | } 456 | 457 | /// Returns the (x, y)-point at t = `t`. This does not take angle into account. 458 | point(t) { 459 | return this.centre_normalised.add(this.origin) 460 | .add(new Point(Math.abs(this.radius), 0) 461 | .rotate(this.start_angle - this.angle + t * this.sweep_angle * this.clockwise)); 462 | } 463 | 464 | /// Returns the angle of the tangent to the curve at t = `t`. This does not take angle into 465 | /// account. 466 | tangent(t) { 467 | return this.start_angle - this.angle 468 | + (t * this.sweep_angle + Math.PI / 2) * this.clockwise; 469 | } 470 | 471 | /// Returns the arc length of the arc from t = 0 to t = `t`. 472 | arc_length(t) { 473 | return t * this.sweep_angle * Math.abs(this.radius); 474 | } 475 | 476 | /// Returns a function giving the parameter t of the point a given length along the arc. The 477 | /// returned function does little error-checking, so the caller is responsible for ensuring it 478 | /// is passed only lengths between 0 and the arc length of the curve. 479 | /// If `clamp` is true, we clamp any `t`s less than 0 or greater than 1. Otherwise, we throw an 480 | /// error. 481 | t_after_length(clamp = false) { 482 | // We assume that the radius and sweep angle are nonzero. 483 | return (length) => { 484 | if (length < 0) { 485 | if (clamp) { 486 | return 0; 487 | } else { 488 | throw new Error("Length was less than 0."); 489 | } 490 | } 491 | if (length > this.arc_length(1)) { 492 | if (clamp) { 493 | return 1; 494 | } else { 495 | throw new Error("Length was greater than the arc length."); 496 | } 497 | } 498 | return length / (this.sweep_angle * Math.abs(this.radius)); 499 | }; 500 | } 501 | 502 | /// Returns the height of the curve. 503 | get height() { 504 | return Math.abs(this.major ? this.radius * 2 - this.sagitta : this.sagitta); 505 | } 506 | 507 | /// Retrusn the width of the curve. 508 | get width() { 509 | return this.major ? Math.abs(this.radius) * 2 : this.chord; 510 | } 511 | 512 | /// Returns whether or not the given angle is contained within the arc. 513 | angle_in_arc(angle) { 514 | const normalise = (angle) => { 515 | while (angle < -Math.PI) angle += 2 * Math.PI; 516 | while (angle > Math.PI) angle -= 2 * Math.PI; 517 | return angle; 518 | }; 519 | 520 | const angle1 = normalise(this.start_angle - angle); 521 | const angle2 = normalise(this.start_angle + this.sweep_angle * this.clockwise - angle); 522 | return (angle1 * angle2 < 0 && Math.abs(angle1 - angle2) < Math.PI) !== this.major; 523 | } 524 | 525 | /// Intersect the arc with the given rounded rectangle. If the rounded rectangle entirely 526 | /// contains the arc, and `permit_containment` is true, a single intersection point (the centre 527 | /// of the rectangle) is returned; otherwise, an error is thrown. 528 | intersections_with_rounded_rectangle(rect, permit_containment) { 529 | // If the arc is essentially a straight line, we pass off intersection checking to the 530 | // Bézier code, which already special cases straight lines. Since the circles involved can 531 | // be very large, it does not suffice to use `EPSILON` here, so we use `1.0` instead. In any 532 | // case, we do not care very much about sub-pixel precision. 533 | if (!this.major && Math.abs(this.sagitta) <= 1.0) { 534 | return new Bezier(this.origin, this.chord, 0, this.angle) 535 | .intersections_with_rounded_rectangle(rect, permit_containment); 536 | } 537 | 538 | // Normalise all the points with respect to the circle. 539 | const points = rect.points().map((p) => { 540 | // Translate the point with respect to the centre of the circle. 541 | p = p.sub(this.centre).map(round_to_epsilon); 542 | return p; 543 | }); 544 | // We wish to return points in order of proximity to the origin, so we must reverse the 545 | // iteration order if we are traversing anticlockwise. 546 | if (this.radius < 0) { 547 | points.reverse(); 548 | } 549 | const intersections = new Set(); 550 | 551 | // We need to find the intersections of line segments with a circle. There may be 0, 1 or 2 552 | // intersections for each segment. 553 | for (let i = 0; i < points.length; ++i) { 554 | const endpoints = [points[i], points[(i + 1) % points.length]]; 555 | const d = endpoints[1].sub(endpoints[0]); 556 | const det = endpoints[0].x * endpoints[1].y - endpoints[1].x * endpoints[0].y; 557 | const ls = d.length() ** 2; 558 | const disc = (this.radius ** 2) * ls - (det ** 2); 559 | if (Math.sign(disc) < 0) { 560 | // No intersection. 561 | continue; 562 | } 563 | // If the sign of `disc` is 0, then the line segment is tangent to the circle. If the 564 | // sign is positive, then there are two intersection points on the circle (though not 565 | // necessarily on the arc). 566 | for (const s of Math.abs(disc) <= EPSILON ? [0] : [1, -1]) { 567 | const [x, y] = [ 568 | (det * d.y + s * d.x * (disc ** 0.5) * (d.y < 0 ? -1 : 1)) / ls, 569 | (-det * d.x + s * (disc ** 0.5) * Math.abs(d.y)) / ls, 570 | ].map(round_to_epsilon); 571 | 572 | // Check that the intersection is on the line segment. 573 | if (x >= Math.min(endpoints[0].x, endpoints[1].x) 574 | && x <= Math.max(endpoints[0].x, endpoints[1].x) 575 | && y >= Math.min(endpoints[0].y, endpoints[1].y) 576 | && y <= Math.max(endpoints[0].y, endpoints[1].y) 577 | ) { 578 | // Check that the intersection is on the arc. 579 | if (this.angle_in_arc(Math.atan2(y, x))) { 580 | Curve.add_intersection(intersections, new Point(x, y)); 581 | } 582 | } 583 | } 584 | } 585 | 586 | // If there are no intersections, check whether the rectangle entirely contains the curve. 587 | if (intersections.size === 0) { 588 | return Curve.check_for_containment(this.origin, rect, permit_containment); 589 | } 590 | 591 | return Array.from(intersections).map((p) => { 592 | const t = mod((Math.atan2(p.y, p.x) - this.start_angle) * this.clockwise, 2 * Math.PI) 593 | / this.sweep_angle; 594 | return new CurvePoint( 595 | p.add(this.centre).sub(this.origin).rotate(-this.angle), 596 | t, 597 | this.tangent(t), 598 | ); 599 | }); 600 | } 601 | 602 | /// Render the arc to an SVG path. 603 | render(path) { 604 | // Firefox appears to have some rendering issues with very large arcs, so we revert to a 605 | // straight line when the difference is minimal. 606 | if (!this.major && Math.abs(this.sagitta) <= 1.0) { 607 | return path.line_by(new Point(this.chord, 0)); 608 | } 609 | return path.arc_by( 610 | Point.diag(Math.abs(this.radius)), 611 | 0, 612 | this.major, 613 | this.radius >= 0, 614 | new Point(this.chord, 0), 615 | ); 616 | } 617 | } 618 | -------------------------------------------------------------------------------- /src/quiver/dom.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { clamp } from "./ds.js" 4 | 5 | /// A helper method to trigger later in the event queue. 6 | export function delay(f, duration = 0) { 7 | setTimeout(f, duration); 8 | } 9 | 10 | /// A helper method to cancel the default behaviour of an event. 11 | export function cancel(event) { 12 | event.preventDefault(); 13 | event.stopPropagation(); 14 | event.stopImmediatePropagation(); 15 | } 16 | 17 | // Older versions of Safari are problematic because they're essentially tied to the macOS version, 18 | // and may not have support for pointer events. In this case, we simply replace them with mouse 19 | // events instead. 20 | // This should behave acceptably, because we don't access many pointer-specific properties in the 21 | // pointer events, and for those that we do, `undefined` will behave as expected. 22 | export function pointer_event(name) { 23 | if (`onpointer${name}` in document.documentElement) { 24 | return `pointer${name}`; 25 | } else { 26 | return `mouse${name}`; 27 | } 28 | } 29 | 30 | /// A helper object for dealing with the DOM. 31 | export const DOM = {}; 32 | 33 | /// A class for conveniently dealing with elements. It's primarily useful in giving us a way to 34 | /// create an element and immediately set properties and styles, in a single statement. 35 | DOM.Element = class { 36 | /// `from` has two forms: a plain string, in which case it is used as a `tagName` for a new 37 | /// element, or an existing element, in which case it is wrapped in a `DOM.Element`. 38 | constructor(from, attributes = {}, style = {}, namespace = null) { 39 | if (from instanceof DOM.Element) { 40 | // Used when we want to convert between different subclasses of `DOM.Element`. 41 | this.element = from.element; 42 | } else if (typeof from !== "string") { 43 | this.element = from; 44 | } else if (namespace !== null) { 45 | this.element = document.createElementNS(namespace, from); 46 | } else { 47 | this.element = document.createElement(from); 48 | } 49 | this.set_attributes(attributes); 50 | this.set_style(style); 51 | } 52 | 53 | get id() { 54 | return this.element.id; 55 | } 56 | 57 | get class_list() { 58 | return this.element.classList; 59 | } 60 | 61 | get parent() { 62 | return new DOM.Element(this.element.parentElement); 63 | } 64 | 65 | /// Appends an element. 66 | /// `value` has three forms: a plain string, in which case it is added as a text node; a 67 | /// `DOM.Element`, in which case the corresponding element is appended; or a plain element. 68 | add(value) { 69 | if (value instanceof DOM.Element) { 70 | this.element.appendChild(value.element); 71 | } else if (typeof value !== "string") { 72 | this.element.appendChild(value); 73 | } else { 74 | this.element.appendChild(document.createTextNode(value)); 75 | } 76 | return this; 77 | } 78 | 79 | /// Appends this element to the given one. 80 | add_to(value) { 81 | if (value instanceof DOM.Element) { 82 | value.element.appendChild(this.element); 83 | } else { 84 | value.appendChild(this.element); 85 | } 86 | return this; 87 | } 88 | 89 | /// Removes the element from the DOM. 90 | remove() { 91 | this.element.remove(); 92 | } 93 | 94 | /// Adds an event listener. 95 | listen(type, f) { 96 | this.element.addEventListener(type, event => f(event, this.element)); 97 | return this; 98 | } 99 | 100 | /// Removes all children from the element. 101 | clear() { 102 | while (this.element.firstChild !== null) { 103 | this.element.firstChild.remove(); 104 | } 105 | return this; 106 | } 107 | 108 | /// Shorthand for `clear().add(...)`. 109 | replace(value) { 110 | return this.clear().add(value); 111 | } 112 | 113 | query_selector(selector) { 114 | const element = this.element.querySelector(selector); 115 | if (element !== null) { 116 | return new DOM.Element(element); 117 | } else { 118 | return null; 119 | } 120 | } 121 | 122 | query_selector_all(selector) { 123 | const elements = Array.from(this.element.querySelectorAll(selector)); 124 | return elements.map((element) => new DOM.Element(element)); 125 | } 126 | 127 | get_attribute(attribute) { 128 | return this.element.getAttribute(attribute); 129 | } 130 | 131 | set_attributes(attributes = {}) { 132 | for (const [attribute, value] of Object.entries(attributes)) { 133 | if (value !== null) { 134 | this.element.setAttribute(attribute, value); 135 | } else { 136 | this.element.removeAttribute(attribute); 137 | } 138 | } 139 | return this; 140 | } 141 | 142 | remove_attributes(...attributes) { 143 | for (const attribute of attributes) { 144 | this.element.removeAttribute(attribute); 145 | } 146 | return this; 147 | } 148 | 149 | set_style(style = {}) { 150 | Object.assign(this.element.style, style); 151 | } 152 | 153 | clone() { 154 | return new DOM.Element(this.element.cloneNode()); 155 | } 156 | 157 | bounding_rect() { 158 | return this.element.getBoundingClientRect(); 159 | } 160 | 161 | dispatch(event) { 162 | this.element.dispatchEvent(event); 163 | return this; 164 | } 165 | 166 | contains(other) { 167 | return this.element.contains(other.element); 168 | } 169 | }; 170 | 171 | DOM.Div = class extends DOM.Element { 172 | constructor(attributes = {}, style = {}) { 173 | super("div", attributes, style); 174 | } 175 | }; 176 | 177 | DOM.Code = class extends DOM.Element { 178 | constructor(value, attributes = {}, style = {}) { 179 | super("code", attributes, style); 180 | this.add(value); 181 | } 182 | }; 183 | 184 | /// A class for conveniently dealing with SVGs. 185 | DOM.SVGElement = class extends DOM.Element { 186 | constructor(tag_name, attributes = {}, style = {}) { 187 | super(tag_name, attributes, style, DOM.SVGElement.NAMESPACE); 188 | } 189 | }; 190 | DOM.SVGElement.NAMESPACE = "http://www.w3.org/2000/svg"; 191 | 192 | /// A class for conveniently dealing with canvases. 193 | DOM.Canvas = class extends DOM.Element { 194 | constructor(from, width, height, attributes = {}, style = {}) { 195 | super(from || "canvas", attributes, style); 196 | this.context = this.element.getContext("2d"); 197 | if (from === null) { 198 | if (typeof width === "undefined" || typeof height === "undefined") { 199 | console.error("`canvas` must have a defined `width` and `height`."); 200 | } 201 | this.resize(width, height); 202 | const dpr = window.devicePixelRatio; 203 | this.context.setTransform(dpr, 0, 0, dpr, 0, 0); 204 | } 205 | } 206 | 207 | /// Resizes and clears the canvas. 208 | resize(width, height) { 209 | const dpr = window.devicePixelRatio; 210 | // Only resize the canvas when necessary. 211 | if (width * dpr !== this.element.width || height * dpr != this.element.height) { 212 | this.element.width = width * dpr; 213 | this.element.height = height * dpr; 214 | this.element.style.width = `${width}px`; 215 | this.element.style.height = `${height}px`; 216 | this.context.setTransform(dpr, 0, 0, dpr, 0, 0); 217 | } else { 218 | this.clear(); 219 | } 220 | } 221 | 222 | /// Clears the canvas. 223 | clear() { 224 | this.context.clearRect(0, 0, this.element.width, this.element.height); 225 | } 226 | } 227 | 228 | /// A class for conveniently dealing with tables. 229 | DOM.Table = class extends DOM.Element { 230 | constructor(rows, attributes = {}, style = {}) { 231 | super("table", attributes, style); 232 | 233 | for (const row of rows) { 234 | const tr = new DOM.Element("tr").add_to(this); 235 | for (const value of row) { 236 | const td = new DOM.Element("td").add_to(tr); 237 | if (typeof value === "function") { 238 | value(td); 239 | } else { 240 | td.add(value); 241 | } 242 | } 243 | } 244 | } 245 | }; 246 | 247 | /// A class for conveniently dealing with lists. 248 | DOM.List = class extends DOM.Element { 249 | constructor(ordered = true, items, attributes = {}, style = {}) { 250 | super(ordered ? "ol" : "ul", attributes, style); 251 | 252 | for (let item of items) { 253 | // Wrap in `
  • ` if necessary. 254 | if (!(item instanceof DOM.Element) || item.element.className !== "li") { 255 | item = new DOM.Element("li").add(item); 256 | } 257 | this.add(item); 258 | } 259 | } 260 | }; 261 | 262 | // A class for conveniently dealing with hyperlinks. 263 | DOM.Link = class extends DOM.Element { 264 | constructor(url, content, new_tab = false, attributes = {}, style = {}) { 265 | super("a", Object.assign({ href: url }, attributes), style); 266 | if (new_tab) { 267 | this.set_attributes({ target: "_blank" }); 268 | } 269 | this.add(content); 270 | } 271 | }; 272 | 273 | // A custom `input[type="range"]` that permits multiple thumbs. 274 | DOM.Multislider = class extends DOM.Element { 275 | constructor(name, min, max, step = 1, thumbs = 1, spacing = 0, attributes = {}, style = {}) { 276 | // The slider element, containing the track and thumbs. 277 | super("div", attributes, style); 278 | this.class_list.add("slider"); 279 | 280 | this.min = min; 281 | this.max = max; 282 | this.step = step; 283 | // By how much to keep thumbs separated. This is only relevant when `thumbs > 1`. 284 | this.spacing = spacing; 285 | 286 | // The track, in which the thumbs are placed. 287 | const track = new DOM.Div({ class: "track" }).add_to(this); 288 | 289 | // The thumbs, which may be dragged by the user. 290 | this.thumbs = []; 291 | for (let i = 0; i < thumbs; ++i) { 292 | new DOM.Multislider.Thumb(this); 293 | } 294 | 295 | if (this.thumbs.length > 0) { 296 | track.listen(pointer_event("move"), (event) => { 297 | if (DOM.Multislider.active_thumb === null) { 298 | // Find the closest thumb to the cursor. 299 | const thumb_proximities = this.thumbs.map((thumb) => { 300 | // Functional programmers, please avert your eyes. 301 | thumb.class_list.remove("hover"); 302 | 303 | const thumb_rect = thumb.bounding_rect(); 304 | return [ 305 | Math.abs(event.clientX - thumb_rect.left - thumb_rect.width / 2), 306 | thumb 307 | ]; 308 | }); 309 | thumb_proximities.sort(([prox1,], [prox2,]) => prox1 - prox2); 310 | // We're "hovering" over the closest thumb. 311 | const [, closest_thumb] = thumb_proximities[0]; 312 | closest_thumb.class_list.add("hover"); 313 | } 314 | }); 315 | 316 | track.listen(pointer_event("down"), (event) => { 317 | const hovered_thumb = this.thumbs.find((thumb) => { 318 | return thumb.class_list.contains("hover"); 319 | }); 320 | if (!this.class_list.contains("disabled") && typeof hovered_thumb !== "undefined") { 321 | event.stopPropagation(); 322 | // Display the currently-dragged thumb above any other. This is important where 323 | // there are multiple thumbs, which can overlap. 324 | this.thumbs.forEach((thumb) => thumb.set_style({ "z-index": 1 })); 325 | const parent = this.label.parent; 326 | if (parent.class_list.contains("linked-sliders")) { 327 | parent.class_list.add("active"); 328 | } 329 | this.class_list.add("active"); 330 | hovered_thumb.class_list.add("active"); 331 | hovered_thumb.move_to_pointer(event); 332 | DOM.Multislider.active_thumb = hovered_thumb; 333 | hovered_thumb.set_style({ "z-index": 2 }); 334 | } 335 | }); 336 | 337 | track.listen(pointer_event("leave"), () => { 338 | this.query_selector_all(".thumb.hover").forEach((thumb) => { 339 | thumb.class_list.remove("hover"); 340 | }); 341 | }); 342 | } 343 | 344 | // The label containing both the slider, and the slider value. 345 | this.label = new DOM.Element("label") 346 | .add(`${name}: `) 347 | .add(this) 348 | .add(new DOM.Element("span", { class: "slider-values" })); 349 | } 350 | 351 | // Returns an array of the values of each of the thumbs, or the sole value if there is only one 352 | // thumb. 353 | values() { 354 | const values = this.thumbs.map((thumb) => thumb.value); 355 | if (values.length === 1) { 356 | return values[0]; 357 | } 358 | return values; 359 | } 360 | }; 361 | 362 | // A draggable thumb on a slider. 363 | DOM.Multislider.Thumb = class extends DOM.Element { 364 | constructor(slider, attributes = {}, style = {}) { 365 | super("div", Object.assign({ 366 | class: `thumb ${(attributes.class || "")}`.trim(), 367 | }, attributes), style); 368 | this.slider = slider.add(this); 369 | this.index = this.slider.thumbs.push(this) - 1; 370 | // We don't want to update the `value` until the slider has been added to the DOM, so we can 371 | // query element sizes. For this reason, we set `value` to `null` to begin with, and rely on 372 | // the caller to `set_value` manually. 373 | this.value = null; 374 | } 375 | 376 | /// Move the thumb to the location of the pointer `event`. 377 | move_to_pointer(event) { 378 | const slider_rect = this.slider.bounding_rect(); 379 | const thumb_width = this.bounding_rect().width; 380 | const x = clamp( 381 | thumb_width / 2, 382 | event.clientX - slider_rect.left, 383 | slider_rect.width - thumb_width / 2, 384 | ); 385 | const value = this.slider.min + Math.round( 386 | (x - thumb_width / 2) / (slider_rect.width - thumb_width) 387 | * (this.slider.max - this.slider.min) / this.slider.step 388 | ) * this.slider.step; 389 | this.set_value(value, true); 390 | 391 | if (this.slider.class_list.contains("symmetric")) { 392 | this.symmetrise(); 393 | } 394 | } 395 | 396 | /// Update this thumb's pair value to make the two symmetric. 397 | symmetrise() { 398 | this.slider.thumbs[this.slider.thumbs.length - (this.index + 1)].set_value( 399 | this.index < this.slider.thumbs.length / 2 ? 400 | this.slider.max - (this.value - this.slider.min) : 401 | this.slider.min + (this.slider.max - this.value), 402 | true, 403 | ); 404 | } 405 | 406 | /// We use a setter, which allows us to update the position of the thumb as well as the value of 407 | /// `this.value`. 408 | /// Returns whether the thumb value was changed (i.e. whether the given `value` was valid and 409 | // different to the current value). 410 | set_value(value, trigger_event = false) { 411 | // Though we clamp the value to `min` and `max`, we permit non-`step` values (though in 412 | // practice, this will not be possible when this event has been triggered by the user). 413 | // `relative_min` and `relative_max` is the minimum/maximum for this thumb taking into 414 | // account the previous/next thumb, and `spacing`. 415 | let relative_min = this.index > 0 ? 416 | this.slider.thumbs[this.index - 1].value + this.slider.spacing : this.slider.min; 417 | let relative_max = this.index + 1 < this.slider.thumbs.length ? 418 | this.slider.thumbs[this.index + 1].value - this.slider.spacing : this.slider.max; 419 | // If the slider is symmetric, then we need to make sure we don't set the thumb to a value 420 | // too close to the centre, where there won't be enough space for the thumb's pair. 421 | if (this.slider.class_list.contains("symmetric")) { 422 | if (this.index < this.slider.thumbs.length / 2) { 423 | relative_max = Math.min( 424 | (this.slider.min + this.slider.max - this.slider.spacing) / 2, 425 | relative_max 426 | ); 427 | } else { 428 | relative_min = Math.max( 429 | (this.slider.min + this.slider.max + this.slider.spacing) / 2, 430 | relative_min 431 | ); 432 | } 433 | } 434 | value = clamp( 435 | Math.max(relative_min, this.slider.min), 436 | value, 437 | Math.min(relative_max, this.slider.max), 438 | ); 439 | 440 | // Although it would make sense to include the position calculation in the following block, 441 | // there are times we want to recalculate the position even when the value has not changed, 442 | // so it's more convenient to set the position regardless. 443 | const slider_rect = this.slider.bounding_rect(); 444 | const thumb_rect = this.bounding_rect(); 445 | this.set_style({ 446 | left: `${ 447 | thumb_rect.width / 2 448 | + (value - this.slider.min) / (this.slider.max - this.slider.min) 449 | * (slider_rect.width - thumb_rect.width) 450 | }px`, 451 | }); 452 | 453 | if (value !== this.value) { 454 | this.value = value; 455 | 456 | const slider_values = this.slider.label.query_selector(".slider-values").clear(); 457 | this.slider.thumbs.forEach((thumb, i) => { 458 | let value = `${thumb.value}`; 459 | if (typeof thumb.value === "number" && !Number.isInteger(thumb.value)) { 460 | // If we're displaying a floating-point number, cap the number of decimal 461 | // places to 2. 462 | value = thumb.value.toFixed(2); 463 | } 464 | slider_values.add(new DOM.Element("span", { class: "slider-value" }) 465 | .add(`${value}`)); 466 | if (i + 1 < this.slider.thumbs.length) { 467 | slider_values.add(" \u2013 "); 468 | } 469 | }); 470 | 471 | // Trigger a `input` event on the thumb (which will bubble up to the slider). 472 | if (trigger_event) { 473 | this.dispatch(new Event("input", { bubbles: true })); 474 | } 475 | 476 | return true; 477 | } 478 | 479 | return false; 480 | } 481 | } 482 | 483 | // The `DOM.Multislider.Thumb` currently being dragged, or `null` if none is. 484 | DOM.Multislider.active_thumb = null; 485 | 486 | // Handle dragging slider thumbs. 487 | window.addEventListener(pointer_event("move"), (event) => { 488 | if (DOM.Multislider.active_thumb !== null) { 489 | DOM.Multislider.active_thumb.move_to_pointer(event); 490 | } 491 | }); 492 | 493 | // Handle the release of slider thumbs. 494 | window.addEventListener(pointer_event("up"), () => { 495 | const active_thumb = DOM.Multislider.active_thumb; 496 | if (active_thumb !== null) { 497 | const parent = active_thumb.slider.label.parent; 498 | if (parent.class_list.contains("linked-sliders")) { 499 | parent.class_list.remove("active"); 500 | } 501 | active_thumb.slider.class_list.remove("active"); 502 | active_thumb.class_list.remove("active"); 503 | DOM.Multislider.active_thumb = null; 504 | } 505 | }); 506 | -------------------------------------------------------------------------------- /src/quiver/ds.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /// An enumeration type. 4 | export class Enum { 5 | constructor(name, ...variants) { 6 | for (const variant of variants) { 7 | this[variant] = Symbol(`${name}::${variant}`); 8 | } 9 | } 10 | } 11 | 12 | /// A quintessential 2D (x, y) point. 13 | export class Point { 14 | constructor(x, y) { 15 | [this.x, this.y] = [x, y]; 16 | } 17 | 18 | static zero() { 19 | return new this(0, 0); 20 | } 21 | 22 | static lendir(length, direction) { 23 | return new this(Math.cos(direction) * length, Math.sin(direction) * length); 24 | } 25 | 26 | static diag(x) { 27 | return new this(x, x); 28 | } 29 | 30 | toString() { 31 | return `${this.x} ${this.y}`; 32 | } 33 | 34 | toArray() { 35 | return [this.x, this.y]; 36 | } 37 | 38 | px(comma = true) { 39 | return `${this.x}px${comma ? "," : ""} ${this.y}px`; 40 | } 41 | 42 | eq(other) { 43 | return this.x === other.x && this.y === other.y; 44 | } 45 | 46 | add(other) { 47 | return new (this.constructor)(this.x + other.x, this.y + other.y); 48 | } 49 | 50 | sub(other) { 51 | return new (this.constructor)(this.x - other.x, this.y - other.y); 52 | } 53 | 54 | neg() { 55 | return new (this.constructor)(-this.x, -this.y); 56 | } 57 | 58 | scale(w, h) { 59 | return new (this.constructor)(this.x * w, this.y * h); 60 | } 61 | 62 | inv_scale(w, h) { 63 | return new (this.constructor)(this.x / w, this.y / h); 64 | } 65 | 66 | mul(multiplier) { 67 | return this.scale(multiplier, multiplier); 68 | } 69 | 70 | div(divisor) { 71 | return this.inv_scale(divisor, divisor); 72 | } 73 | 74 | max(other) { 75 | return new (this.constructor)(Math.max(this.x, other.x), Math.max(this.y, other.y)); 76 | } 77 | 78 | min(other) { 79 | return new (this.constructor)(Math.min(this.x, other.x), Math.min(this.y, other.y)); 80 | } 81 | 82 | rotate(theta) { 83 | return new (this.constructor)( 84 | this.x * Math.cos(theta) - this.y * Math.sin(theta), 85 | this.y * Math.cos(theta) + this.x * Math.sin(theta), 86 | ); 87 | } 88 | 89 | length() { 90 | return Math.hypot(this.y, this.x); 91 | } 92 | 93 | angle() { 94 | return Math.atan2(this.y, this.x); 95 | } 96 | 97 | lerp(other, t) { 98 | return this.add(other.sub(this).mul(t)); 99 | } 100 | 101 | is_zero() { 102 | return this.x === 0 && this.y === 0; 103 | } 104 | 105 | map(f) { 106 | return new (this.constructor)(f(this.x), f(this.y)); 107 | } 108 | } 109 | 110 | /// Equivalent to `Point`, but used semantically to refer to a position (in cell indices) 111 | /// on the canvas. 112 | export class Position extends Point {} 113 | 114 | /// Equivalent to `Point`, but used semantically to refer to a position (in pixels) on the canvas. 115 | export class Offset extends Point {} 116 | 117 | /// An (width, height) pair. This is essentially functionally equivalent to `Point`, 118 | /// but has different semantic intent. 119 | export const Dimensions = class extends Position { 120 | get width() { 121 | return this.x; 122 | } 123 | 124 | get height() { 125 | return this.y; 126 | } 127 | }; 128 | 129 | /// Convert radians to degrees. 130 | export function rad_to_deg(rad) { 131 | return rad * 180 / Math.PI; 132 | } 133 | 134 | /// Convert degrees to radians. 135 | export function deg_to_rad(deg) { 136 | return deg * Math.PI / 180; 137 | } 138 | 139 | /// A class for conveniently generating and manipulating SVG paths. 140 | export class Path { 141 | constructor() { 142 | this.commands = []; 143 | } 144 | 145 | toString() { 146 | return this.commands.join("\n"); 147 | } 148 | 149 | move_to(p) { 150 | this.commands.push(`M ${p.x} ${p.y}`); 151 | return this; 152 | } 153 | 154 | move_by(p) { 155 | this.commands.push(`m ${p.x} ${p.y}`); 156 | return this; 157 | } 158 | 159 | line_to(p) { 160 | if (p.x === 0) { 161 | this.commands.push(`V ${p.y}`); 162 | } else if (p.y === 0) { 163 | this.commands.push(`H ${p.x}`); 164 | } else { 165 | this.commands.push(`L ${p.x} ${p.y}`); 166 | } 167 | return this; 168 | } 169 | 170 | line_by(p) { 171 | if (p.x === 0) { 172 | this.commands.push(`v ${p.y}`); 173 | } else if (p.y === 0) { 174 | this.commands.push(`h ${p.x}`); 175 | } else { 176 | this.commands.push(`l ${p.x} ${p.y}`); 177 | } 178 | return this; 179 | } 180 | 181 | curve_by(c, d) { 182 | this.commands.push(`q ${c.x} ${c.y} ${d.x} ${d.y}`); 183 | return this; 184 | } 185 | 186 | arc_by(r, angle, large_arc, clockwise, next) { 187 | this.commands.push( 188 | `a ${r.x} ${r.y} 189 | ${rad_to_deg(angle)} ${large_arc ? 1 : 0} ${clockwise ? 1 : 0} 190 | ${next.x} ${next.y}` 191 | ); 192 | return this; 193 | } 194 | } 195 | 196 | export function clamp(min, x, max) { 197 | return Math.max(min, Math.min(x, max)); 198 | } 199 | 200 | export function arrays_equal(array1, array2) { 201 | if (array1.length !== array2.length) { 202 | return false; 203 | } 204 | 205 | for (let i = 0; i < array1.length; ++i) { 206 | if (array1[i] !== array2[i]) { 207 | return false; 208 | } 209 | } 210 | 211 | return true; 212 | } 213 | 214 | export function mod(x, y) { 215 | return (x % y + y) % y; 216 | } 217 | 218 | // A type with custom JSON encoding. 219 | class Encodable { 220 | eq(/* other */) { 221 | console.error("`eq` must be implemented for each subclass."); 222 | } 223 | } 224 | 225 | export class Colour extends Encodable { 226 | constructor(h, s, l, a = 1, name = Colour.colour_name([h, s, l, a])) { 227 | super(); 228 | [this.h, this.s, this.l, this.a] = [h, s, l, a]; 229 | this.name = name; 230 | } 231 | 232 | static black() { 233 | return new Colour(0, 0, 0); 234 | } 235 | 236 | /// Returns a standard colour name associated to the `[h, s, l, a]` value, or `null` if none 237 | /// exists. Currently, this is only used to associate tooltips to colours swatches in the UI. 238 | static colour_name(hsla) { 239 | const [h, s, l, a] = hsla; 240 | if (a === 0) { 241 | return "transparent"; 242 | } 243 | if (a === 1 && l === 0) { 244 | return "black"; 245 | } 246 | if (a === 1 && l === 100) { 247 | return "white"; 248 | } 249 | 250 | switch (`${h}, ${s}, ${l}, ${a}`) { 251 | // Most of the following colours match the CSS colour names. Those that do not have (*) 252 | // next to them. 253 | case "0, 100, 50, 1": 254 | return "red"; 255 | case "30, 100, 50, 1": 256 | return "orange"; // (*) 257 | case "60, 100, 50, 1": 258 | return "yellow"; 259 | case "120, 100, 50, 1": 260 | return "green"; 261 | case "180, 100, 50, 1": 262 | return "aqua"; 263 | case "240, 100, 50, 1": 264 | return "blue"; 265 | case "270, 100, 50, 1": // (*) 266 | return "purple"; 267 | case "300, 100, 50, 1": 268 | return "magenta"; 269 | // The following do not match CSS colour names. 270 | case "0, 60, 60, 1": 271 | return "red chalk"; 272 | case "30, 60, 60, 1": 273 | return "orange chalk"; 274 | case "60, 60, 60, 1": 275 | return "yellow chalk"; 276 | case "120, 60, 60, 1": 277 | return "green chalk"; 278 | case "180, 60, 60, 1": 279 | return "aqua chalk"; 280 | case "240, 60, 60, 1": 281 | return "blue chalk"; 282 | case "270, 60, 60, 1": 283 | return "purple chalk"; 284 | case "300, 60, 60, 1": 285 | return "magenta chalk"; 286 | } 287 | return null; 288 | } 289 | 290 | hsla() { 291 | return [this.h, this.s, this.l, this.a]; 292 | } 293 | 294 | rgba() { 295 | // Algorithm source: https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative. 296 | const [h, s, l] = [this.h, this.s / 100, this.l / 100]; 297 | const a = s * Math.min(l, 1 - l); 298 | const f = (n) => { 299 | const k = (n + h / 30) % 12; 300 | return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); 301 | } 302 | return [f(0) * 255, f(8) * 255, f(4) * 255, this.a].map((x) => Math.round(x)); 303 | } 304 | 305 | /// `r`, `g`, `b` are expected to take values in `0` to `255`. 306 | static from_rgba(r, g, b, a = 1) { 307 | // Algorithm source: https://en.wikipedia.org/wiki/HSL_and_HSV#Formal_derivation 308 | [r, g, b] = [r, g, b].map((x) => x / 255); 309 | const max = Math.max(r, g, b); 310 | const min = Math.min(r, g, b); 311 | const range = max - min; 312 | let h = 0; // Default hue (if undefined). 313 | if (range !== 0) { 314 | switch (max) { 315 | case r: 316 | h = ((g - b) / range) % 6; 317 | break; 318 | case g: 319 | h = ((b - r) / range) + 2; 320 | break; 321 | case b: 322 | h = ((r - g) / range) + 4; 323 | break; 324 | } 325 | } 326 | const l = (max + min) / 2; 327 | const s = l === 0 || l === 1 ? 0 : range / (1 - Math.abs(2 * l - 1)); 328 | 329 | return new Colour(...[h * 60, s * 100, l * 100].map((x) => Math.round(x)), a); 330 | } 331 | 332 | toJSON() { 333 | if (this.a === 1) { 334 | // For now, every colour has no transparency; even in the future, most 335 | // arrows will be fully opaque, so no point encoding the `1` every time. 336 | return [this.h, this.s, this.l]; 337 | } else { 338 | return this.hsla(); 339 | } 340 | } 341 | 342 | toString() { 343 | return `${this.h},${this.s},${this.l},${this.a}`; 344 | } 345 | 346 | css() { 347 | return `hsla(${this.h}, ${this.s}%, ${this.l}%, ${this.a})`; 348 | } 349 | 350 | /// Returns the LaTeX code corresponding to a HSL colour. 351 | latex(latex_colours, parenthesise = false) { 352 | // If the colour has a specific name in LaTeX (e.g. because it is predefined, or has been 353 | // imported), use that. 354 | let latex_name = null; 355 | const name = Colour.colour_name(this.hsla()); 356 | if (["black", "red", "green", "blue", "white"].includes(name)) { 357 | latex_name = name; 358 | } else { 359 | // We currently eagerly pick whichever LaTeX colour matches this one. This means that if 360 | // there are multiple names for the same colour, we may not pick the correct one. It 361 | // would be possible to correct this by saving colour names, rather than just colour 362 | // values. 363 | for (const [name, colour] of latex_colours) { 364 | if (colour.eq(this)) { 365 | latex_name = name; 366 | break; 367 | } 368 | } 369 | } 370 | if (latex_name !== null) { 371 | return parenthesise ? `{${latex_name}}` : latex_name; 372 | } 373 | 374 | // Otherwise, fall back to a colour code. 375 | // Alpha is currently not supported. 376 | const [r, g, b, /* a */] = this.rgba(); 377 | return `{rgb,255:red,${r};green,${g};blue,${b}}`; 378 | } 379 | 380 | /// Returns whether two colours are equal, ignoring names. 381 | eq(other) { 382 | return this.h === other.h && this.s === other.s && this.l === other.l && this.a === other.a 383 | || this.l === 0 && other.l === 0 || this.l === 100 && other.l === 100; 384 | } 385 | 386 | is_not_black() { 387 | return this.l > 0; 388 | } 389 | } 390 | 391 | /// Returns a `Map` containing the current URL's query parameters, as well as parameters stored in 392 | /// the fragment identifier. If a parameter is found in both the query string and the fragment 393 | /// identifier, the query string parameter is prioritised. 394 | export function url_parameters() { 395 | let data = []; 396 | const fragment_string = window.location.hash.replace(/^#/, ""); 397 | if (fragment_string !== "") { 398 | data = data.concat(fragment_string.split("&").map(segment => segment.split("="))); 399 | } 400 | const query_string = window.location.href.match(/\?(.*)$/); 401 | if (query_string !== null) { 402 | data = data.concat(query_string[1].split("&").map(segment => segment.split("="))); 403 | } 404 | return new Map(data); 405 | } 406 | -------------------------------------------------------------------------------- /src/quiver/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LocalCharts/silviculture/09587c408998412a7f869d12ad644b72b8da23a5/src/quiver/icon.png -------------------------------------------------------------------------------- /src/quiver/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | quiver: a modern commutative diagram editor 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 |
    52 | 53 | 54 | 55 | 56 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /src/quiver/main.css: -------------------------------------------------------------------------------- 1 | /* Root styles */ 2 | 3 | *, *::before, *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | :root { 8 | /* UI colours. */ 9 | --ui-black: hsla(0, 0%, 0%, 0.9); /* Almost black. */ 10 | --ui-white: hsl(0, 0%, 96%); /* Almost white. */ 11 | --ui-blue: hsl(200, 100%, 50%); /* Selection blue. */ 12 | --ui-orange: hsl(30, 100%, 50%); /* Highlight orange. */ 13 | 14 | --ui-background: transparent; 15 | --ui-border: hsla(0, 0%, 100%, 0.2); 16 | --ui-hover: hsla(0, 0%, 100%, 0.1); 17 | --ui-active: hsla(0, 0%, 100%, 0.2); 18 | --ui-focus: var(--ui-blue); 19 | --ui-text: hsla(0, 0%, 100%, 0.6); 20 | 21 | /* Cell states. */ 22 | --cell-hover: hsla(0, 0%, 0%, 0.1); 23 | --cell-selected: hsla(0, 0%, 0%, 0.2); 24 | --cell-source: hsla(0, 0%, 0%, 0.2); 25 | --cell-target: hsla(0, 0%, 0%, 0.2); 26 | } 27 | 28 | body { 29 | position: absolute; 30 | width: 100%; height: 100%; 31 | margin: 0; 32 | overflow: hidden; 33 | 34 | background: white; 35 | 36 | font-family: Arial, sans-serif; 37 | 38 | /* Safari on iOS does not currently respect `none`: double-tap-to-zoom is not disabled. We are 39 | forced to prevent this in JavaScript instead. */ 40 | touch-action: none; 41 | } 42 | 43 | body:not(.modal) { 44 | user-select: none; 45 | } 46 | 47 | noscript { 48 | display: inline-block; 49 | position: fixed; 50 | left: 50%; top: 50%; 51 | padding: 6pt 8pt; 52 | transform: translate(-50%, -50%); 53 | 54 | background: var(--ui-black); 55 | border-radius: 8px; 56 | 57 | text-align: center; 58 | color: var(--ui-white); 59 | } 60 | 61 | /* We occasionally want to disable transitions on a particular element. */ 62 | .no-transition { 63 | transition: none !important; 64 | } 65 | 66 | /* Special elements */ 67 | 68 | .error-banner { 69 | position: fixed; 70 | width: 100%; 71 | left: 0; top: 0; 72 | z-index: 200; 73 | padding: 8px 0; 74 | 75 | background: hsl(0, 50%, 50%); 76 | color: var(--ui-white); 77 | 78 | text-align: center; 79 | 80 | user-select: none; 81 | 82 | transition: transform 0.2s; 83 | } 84 | 85 | .error-banner.hidden { 86 | transform: translateY(-100%); 87 | } 88 | 89 | .close { 90 | width: 28px; height: 28px; 91 | margin-left: 20px; 92 | 93 | background: transparent; 94 | border: none; 95 | border-radius: 100%; 96 | outline: none; 97 | 98 | text-align: center; 99 | color: white; 100 | font-size: 20px; 101 | } 102 | 103 | .close:hover { 104 | background: hsla(0, 0%, 100%, 0.2); 105 | } 106 | 107 | .close:active { 108 | background: hsla(0, 0%, 100%, 0.4); 109 | } 110 | 111 | .close::before { 112 | content: "×"; 113 | } 114 | 115 | a > .logo { 116 | position: fixed; 117 | left: 16px; top: 16px; 118 | width: 100px; 119 | z-index: 90; 120 | 121 | opacity: 0.8; 122 | } 123 | 124 | a > .logo:hover { 125 | opacity: 1; 126 | } 127 | 128 | .version.hidden { 129 | display: none; 130 | } 131 | 132 | .version { 133 | position: fixed; 134 | left: 50px; top: 48px; 135 | z-index: 90; 136 | 137 | font-size: 8pt; 138 | color: var(--ui-black); 139 | 140 | pointer-events: none; 141 | } 142 | 143 | .tooltip { 144 | display: block; 145 | position: absolute; 146 | left: 50%; 147 | padding: 8px 12px; 148 | line-height: 18pt; 149 | transform: translateX(-50%); 150 | z-index: 80; 151 | 152 | background: var(--ui-blue); 153 | border-radius: 4px; 154 | 155 | text-align: center; 156 | color: var(--ui-black); 157 | } 158 | 159 | .ui > .tooltip { 160 | /* Display just below the toolbar. */ 161 | top: calc(16px + 48px + 8px); 162 | } 163 | 164 | kbd { 165 | white-space: pre; 166 | } 167 | 168 | .tooltip kbd, .pane kbd, .port kbd { 169 | padding: 1px 6px; 170 | 171 | border-radius: 4px; 172 | box-shadow: hsla(0, 0%, 0%, 0.1) 0 2px 0px; 173 | } 174 | 175 | .tooltip kbd { 176 | background: var(--ui-white); 177 | border: hsla(0, 0%, 0%, 0.4) 1px solid; 178 | } 179 | 180 | .tooltip kbd + kbd, .pane kbd + kbd { 181 | margin-left: 4px; 182 | } 183 | 184 | .loading-screen { 185 | position: fixed; 186 | left: 0; right: 0; 187 | top: 0; bottom: 0; 188 | z-index: 400; 189 | 190 | background: hsl(0, 0%, 10%); 191 | } 192 | 193 | .loading-screen.hidden { 194 | opacity: 0; 195 | 196 | pointer-events: none; 197 | 198 | transition: opacity 0.6s; 199 | } 200 | 201 | .loading-screen .logo { 202 | position: fixed; 203 | left: 50%; top: 50%; 204 | width: 288px; 205 | transform: translate(-50%, -50%); 206 | } 207 | 208 | .loading-screen span { 209 | display: inline-block; 210 | position: fixed; 211 | left: 50%; top: 50%; 212 | transform: translate(-50%, -50%); 213 | margin-left: 20px; margin-top: 32px; 214 | 215 | color: var(--ui-blue); 216 | white-space: nowrap; 217 | } 218 | 219 | .embedded .loading-screen { 220 | background: white; 221 | } 222 | 223 | .embedded .loading-screen .logo { 224 | max-width: 60%; 225 | } 226 | 227 | .embedded .loading-screen span { 228 | /* Hide the "Loading diagram..." text in an `