diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..e9037d2 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,39 @@ +# Validates and builds every pull request, so a mistake shows up on the change +# that caused it rather than after it reaches main. +# +# It builds as well as validates because validate.js checks metadata and file +# relationships without executing components — a post body reading a document +# that no longer exists passes validation and then dies during prerendering. +# Compile errors and bad imports are the same class. +# +# Nothing is published from here; see deploy.yml. + +name: Check + +on: + pull_request: + # Lets you re-run the checks by hand from the Actions tab. + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # ci rather than install: installs exactly what package-lock.json records, + # so a dependency publishing a new compatible version can't change the + # result between runs of identical code. + - run: npm ci + + # Warnings do not fail this. They describe unfinished work — a chart not + # yet exported, an image not yet quantised — which shouldn't block a + # branch. They print in the log; --strict is available if that changes. + - run: npm run validate + + - run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0e80533 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,63 @@ +# Builds the site and publishes it to GitHub Pages. +# +# build/ is gitignored and never committed. This workflow builds it on a runner, +# uploads it as a Pages artifact, and Pages serves that — GitHub's documentation: +# "Pages serves uploaded artifacts rather than reading directly from the +# repository." So the built files never become git objects. +# +# Requires Settings -> Pages -> Source set to "GitHub Actions", not "Deploy from +# a branch". Without that, this runs and publishes nothing. + +name: Deploy + +on: + push: + branches: [main] + # Editing notes shouldn't redeploy the site. + paths-ignore: + - 'notes/**' + - '**.md' + workflow_dispatch: + +# What the workflow is allowed to do. id-token is required by deploy-pages. +permissions: + contents: read + pages: write + id-token: write + +# Two quick pushes shouldn't race to publish; the later one wins. +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + # npm run build is validate.js, then vite build, then strip-css.js. A + # validation error exits non-zero, so the build stops, so the deploy job + # below never runs and whatever is already published stays online. + - run: npm run build + + - uses: actions/upload-pages-artifact@v3 + with: + path: build + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf791c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +local_data +node_modules +build +notes +.ipynb_checkpoints +.claude +.DS_Store +.vscode +.svelte-kit +.env +.env.* +!.env.example +!.env.test +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..815e522 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License"). + +You can read the full license text at: +https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode + +Summary (not a substitute for the full license): + +You are free to: +- Share — copy and redistribute the material in any medium or format +- Adapt — remix, transform, and build upon the material + +Under the following terms: +- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. +- NonCommercial — You may not use the material for commercial purposes. +- ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original. + +No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits. \ No newline at end of file diff --git a/ggh-transport-geography/index.html b/ggh-transport-geography/index.html deleted file mode 100644 index 3e5e17a..0000000 --- a/ggh-transport-geography/index.html +++ /dev/null @@ -1,3 +0,0 @@ - - -:) :) :) diff --git a/index.html b/index.html deleted file mode 100644 index 07e204a..0000000 --- a/index.html +++ /dev/null @@ -1,3 +0,0 @@ - - -:) :) :) diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..85a0cd1 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "moduleResolution": "bundler" + }, + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.js", + "svelte.config.js", + "src/**/*.js", + "src/**/*.svelte", + "posts/**/*.js", + "posts/**/*.svelte", + "scripts/**/*.js" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..10a3343 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2183 @@ +{ + "name": "maps-and-data-visualization", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maps-and-data-visualization", + "version": "0.0.1", + "dependencies": { + "@rollup/plugin-dsv": "^3.0.5", + "d3": "^7.9.0", + "topojson-client": "^3.1.0" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.69.0", + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "playwright": "^1.62.1", + "svelte": "^5.56.0", + "vite": "^7.0.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-dsv": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@rollup/plugin-dsv/-/plugin-dsv-3.0.5.tgz", + "integrity": "sha512-q1U4vu7voJkgeltujKya7ByxuWaHg9lYqCFQfWkqcgvIeOlUh52VrQSlgEe1JhN4wIaw5FOflRX3Jv0nqrw9uQ==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/d3-dsv": "^3.0.0", + "d3-dsv": "2.0.0", + "strip-bom": "^4.0.0", + "tosource": "^2.0.0-alpha.3" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-2.0.0.tgz", + "integrity": "sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "2", + "iconv-lite": "0.4", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json", + "csv2tsv": "bin/dsv2dsv", + "dsv2dsv": "bin/dsv2dsv", + "dsv2json": "bin/dsv2json", + "json2csv": "bin/json2dsv", + "json2dsv": "bin/json2dsv", + "json2tsv": "bin/json2dsv", + "tsv2csv": "bin/dsv2dsv", + "tsv2json": "bin/dsv2json" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3/node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/devalue": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.6.tgz", + "integrity": "sha512-yc0OC12UjPqLoc+fe+v5GNs4TOjAigUw3sTikfC+xeBPGUw7gDRz3DtYaqEhxyMVJojcSWJw7jT0QWR+CbuE/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/svelte": { + "version": "5.56.10", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz", + "integrity": "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/tosource": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/tosource/-/tosource-2.0.0-alpha.3.tgz", + "integrity": "sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==", + "engines": { + "node": ">=10" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..417fafd --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "maps-and-data-visualization", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "node scripts/validate.js && vite build && node scripts/strip-css.js", + "preview": "vite preview", + "validate": "node scripts/validate.js", + "validate:strict": "node scripts/validate.js --strict", + "export-pngs": "node scripts/export-png.js", + "optimize-pngs": "node scripts/optimize-png.js", + "strip-css": "node scripts/strip-css.js", + "prepare": "svelte-kit sync || echo ''" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.8", + "@sveltejs/kit": "^2.69.0", + "@sveltejs/vite-plugin-svelte": "^6.0.0", + "playwright": "^1.62.1", + "svelte": "^5.56.0", + "vite": "^7.0.4" + }, + "dependencies": { + "@rollup/plugin-dsv": "^3.0.5", + "d3": "^7.9.0", + "topojson-client": "^3.1.0" + } +} diff --git a/posts/foreign-buyer-tax-vancouver-toronto/+page.svelte b/posts/foreign-buyer-tax-vancouver-toronto/+page.svelte new file mode 100644 index 0000000..00e1d0f --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/+page.svelte @@ -0,0 +1,95 @@ + + +
+ +

+ Foreign buyer taxes (FBTs) are additional property-transfer taxes charged only to buyers who are not Canadian citizens or permanent residents. The British Columbia provincial government introduced the first FBT in Canada in August 2016, a 15% surcharge on residential purchases in Metro Vancouver. Ontario followed in April 2017 with a 15% tax across the Greater Golden Horseshoe. Both provinces later raised their rates and expanded the areas covered, and the Federal government added a temporary ban on foreign purchases in 2023. +

+ +

+ A stated goal of FBTs was to improve housing affordability for local residents by discouraging foreign speculative buying. +

+ +

+ The charts on this page are adapted from research by Taesoo Song (2025), published in the Journal of Urban Affairs, part of which examines what these taxes actually achieved in terms of housing market outcomes: 1) whether the taxes changed who owns homes in the cities they targeted, and 2) whether they made renting more affordable in those same areas. +

+ +
+ + + + + + + +
+ + +

+ Because FBTs apply specifically to non-permanent residents, immigrants who have not yet obtained citizenship or permanent residency, one direct analysis is whether home ownership fell for that group after the taxes took effect. It did. In both Vancouver and Toronto, based on Statistics Canada data, the share of non-permanent residents living in an owner-occupied home dropped sharply between 2016 and 2021, a pattern not seen among Canadian-born or permanent-resident households, or in cities without a tax. +

+ +
+ + + + + +
+ +

+ To look at impacts on rents, Song (2025) estimated the effect of foreign buyer taxes using dynamic difference-in-differences models. This compares median rent in areas subject to a foreign buyer tax against similar areas that weren't, from 2010 to 2021, controlling for each area's employment, recent immigration, and new housing supply. For each year, it reports how far median rents in taxed areas diverged from untaxed areas, relative to a baseline set just before that region's tax took effect. +

+ +

+ The chart below presents the model results as each year's percentage gap in rent growth between taxed and untaxed areas, relative to a baseline year. The baseline is the year before the region's FBT started. Dots are colored by whether that year falls before or after the region's tax was introduced. +

+ +
+ + + + + + + +
+ +
+ About the dynamic difference-in-differences models +

+ A standard difference-in-differences model compares one group's change over time to another group's change, before and after a policy, and treats the gap between them as the estimated effect. A dynamic version, also called an event study, estimates that gap separately for each year rather than assuming one constant effect for the whole post-policy period. That makes it possible to see how the effect builds or fades over time, and to check that taxed and untaxed areas were not already trending apart before the tax existed. +

+

+ For each census subdivision (i.e. municipality) and year, Song's (2025) model can be described roughly as: median rent (logged) equals a baseline, plus a separate effect for each year the area was taxed, plus fixed effects for area and year, plus controls for local employment, recent immigration, and new housing supply. The area and year fixed effects account for anything constant about a specific place, or common to a specific year, that isn't the tax itself, so the remaining year-by-year effects reflect the tax's association with rents specifically. +

+

+ Because rent enters the model in log form, each year's estimated effect (reported in the paper's Table A1) is a log-point difference rather than a percentage. The chart on this page converts those coefficients into percentages using (e^coefficient − 1) × 100, the standard back-transformation for a log-linear model. +

+

+ A few caveats from the paper are worth noting. The approach assumes taxed and untaxed areas would have followed similar rent trends without the tax. Pre-tax years show no significant gap, which supports that assumption but doesn't fully confirm it. Effects for 2020 and 2021 may also partly reflect pandemic-era disruption to rental markets, which isn't separately accounted for. And the underlying CMHC rental data excludes secondary rentals such as condos and houses rented out by their owners, so any demand that shifted specifically into that segment wouldn't be captured by this data. For British Columbia, the untaxed comparison group is limited to regions never subject to an FBT, which excludes regions where the tax was extended starting in 2018. +

+

+ For the full model specification and complete results, including standard errors and significance levels for every year, see Table A1 in Song (2025). +

+
+ +

+ Taken together, these results suggest the taxes fell short of their affordability goal. Rents in taxed areas of British Columbia actually rose faster than in untaxed areas after 2016, reaching an estimated 12.7% higher by 2019. Ontario's effect was smaller and mostly not statistically significant. Meanwhile, non-permanent residents, the group the tax directly targets, saw home ownership decline in exactly the cities where the tax applied, with no equivalent shift elsewhere. One likely explanation, according to Song (2025), is that the tax did not reduce housing demand so much as redirect it. By making ownership harder for non-permanent residents, it appears to have pushed some of that group into the rental market instead, adding pressure to rents in the very areas the tax was meant to help. +

+ +

+ Read the School of Cities Policy Brief, which describes what these findings mean for policymakers weighing similar measures. +

+ +
diff --git a/posts/foreign-buyer-tax-vancouver-toronto/charts.js b/posts/foreign-buyer-tax-vancouver-toronto/charts.js new file mode 100644 index 0000000..25011bd --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/charts.js @@ -0,0 +1,48 @@ +import { resolveDownloads } from '$lib/downloads.js'; + +const dataUrls = import.meta.glob('./data/*', { eager: true, query: '?url', import: 'default' }); +const pngUrls = import.meta.glob('./published/*/*.png', { + eager: true, + query: '?url', + import: 'default' +}); +const embedMeta = import.meta.glob('./published/*/embed.json', { + eager: true, + import: 'default' +}); + +const charts = { + 'policy-timeline': { + title: 'Foreign buyer tax (FBT) policy timeline', + subtitle: 'Key interventions in Ontario, British Columbia, and at the Federal level, 2015–2023', + source: 'Song, T. (2025). Anti-immigration and the politics of housing affordability: Examining the evolution and housing market impacts of Foreign Buyer Taxes in British Columbia and Ontario. Journal of Urban Affairs', + alt: 'Timeline chart showing foreign buyer tax and related housing policy changes in Ontario, British Columbia, and at the Federal level from 2015 to 2023', + data: 'policy-timeline.csv', + widths: [1080], + graphicVerb: "adapted" + }, + 'homeownership-by-citizenship': { + title: 'Home ownership rates among non-permanent residents declined in Vancouver and Toronto after foreign buyer taxes (FBT) were introduced', + subtitle: + 'No similar decline for Canadian-born or permanent residents, or in Montreal and Edmonton, where no tax was introduced', + source: 'Statistics Canada Table 43-10-0060-01 via Song, T. (2025). Anti-immigration and the politics of housing affordability: Examining the evolution and housing market impacts of Foreign Buyer Taxes in British Columbia and Ontario. Journal of Urban Affairs.', + alt: 'Small multiples line chart showing owner-occupancy rates by citizenship status (Canadian-born, naturalized citizens and permanent residents, non-permanent residents) for Vancouver, Toronto, Montreal, and Edmonton CMAs from 2006 to 2021', + data: 'homeownership-by-citizenship.csv', + widths: [540, 1080], + graphicVerb: "adapted" + }, + 'rent-gap': { + title: 'Foreign buyer tax (FBT) did not deliver rental affordability in British Columbia or Ontario', + subtitle: + 'British Columbia rents rose up to an estimated 12.7% faster than untaxed areas by 2019; Ontario saw little to no effect', + source: + 'Song, T. (2025). Anti-immigration and the politics of housing affordability: Examining the evolution and housing market impacts of Foreign Buyer Taxes in British Columbia and Ontario. Journal of Urban Affairs. Log-point coefficients from dynamic difference-in-difference models in Table A1 were converted to percentages for this chart.', + alt: 'Two side-by-side scatter plots showing the percentage gap in median rent between foreign-buyer-tax areas and untaxed areas, for British Columbia (2010-2021) and Ontario (2010-2021), with a marker for when each region introduced its tax', + data: 'rent-gap.csv', + widths: [540, 1080], + graphicVerb: "created", + note: "CMHC rental data excludes secondary rentals such as condos and houses rented out by their owners, so any demand that shifted specifically into that segment wouldn't be captured by this data. For British Columbia, the untaxed comparison group is limited to regions never subject to an FBT, which excludes regions where the tax was extended starting in 2018." + } +}; + +export default resolveDownloads(charts, { dataUrls, pngUrls, embedMeta }); diff --git a/posts/foreign-buyer-tax-vancouver-toronto/charts/homeownership-by-citizenship.svelte b/posts/foreign-buyer-tax-vancouver-toronto/charts/homeownership-by-citizenship.svelte new file mode 100644 index 0000000..eae610f --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/charts/homeownership-by-citizenship.svelte @@ -0,0 +1,255 @@ + + +
+ Share of population in an owner-occupied home, by citizenship status (%) +
+ +
+ {#each seriesOrder as name} + {@const s = seriesStyle[name]} +
+ + + + + {name} +
+ {/each} +
+ +
+ {#each panels as panel} +
+ + {panel.cma} + {fbtInfo[panel.cma]} + + {#each yTicks as tick} + + {tick}% + {/each} + + {#each years as year, i} + {year} + {/each} + + {#each panel.series as s} + `${p.x},${p.y}`).join(' ')} + fill="none" + stroke={s.color} + stroke-width={s.width} + /> + + {#each s.points as p} + + + + {s.name}, {p.year}: {fmt(p.value)}% + + + + {#if s.label} + {@const isBelow = + (panel.cma === 'Vancouver CMA' || panel.cma === 'Toronto CMA') && + p.i === years.length - 1} + + {fmt(p.value)}% + + {/if} + {/each} + {/each} + +
+ {/each} +
+ + diff --git a/posts/foreign-buyer-tax-vancouver-toronto/charts/policy-timeline.svelte b/posts/foreign-buyer-tax-vancouver-toronto/charts/policy-timeline.svelte new file mode 100644 index 0000000..51fccf1 --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/charts/policy-timeline.svelte @@ -0,0 +1,191 @@ + + + + {#each years as year} + + {/each} + + {#each yearLabels as yl} + {yl.year} + {/each} + + {#each rowOrder as row} + + {/each} + + {#each rowOrder as row} + + {row} + {/each} + + {#each events as d} + + + + {#if d.lines.length === 2} + {d.lines[0]} + {d.lines[1]} + {:else} + {d.lines[0]} + {/if} + + {d.date} + {/each} + + + diff --git a/posts/foreign-buyer-tax-vancouver-toronto/charts/rent-gap.svelte b/posts/foreign-buyer-tax-vancouver-toronto/charts/rent-gap.svelte new file mode 100644 index 0000000..075c6dc --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/charts/rent-gap.svelte @@ -0,0 +1,250 @@ + + +
+ Estimated extra median rent growth in taxed areas compared to untaxed areas, relative to the year before the tax (%) +
+ +
+ {#each periodOrder as period} + {@const s = periodStyle[period]} +
+ + + + {periodLabels[period]} +
+ {/each} +
+ +
+ {#each panels as panel} +
+ + {regionLabels[panel.region]} + {fbtInfo[panel.region]} + + {#each yTicks as tick} + + {tick}% + {/each} + + + + {#each years as year} + {year} + {/each} + + {#each panel.points as p} + {#if !p.isBaseline} + + {/if} + + + + {fmt(p.value)}% + + + {/each} + + Baseline + +
+ {/each} +
+ + diff --git a/posts/foreign-buyer-tax-vancouver-toronto/data/homeownership-by-citizenship.csv b/posts/foreign-buyer-tax-vancouver-toronto/data/homeownership-by-citizenship.csv new file mode 100644 index 0000000..684f3b9 --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/data/homeownership-by-citizenship.csv @@ -0,0 +1,49 @@ +cma,series,year,value +Vancouver CMA,Canadian-born,2006,72.9 +Vancouver CMA,Canadian-born,2011,72.7 +Vancouver CMA,Canadian-born,2016,71.4 +Vancouver CMA,Canadian-born,2021,69.2 +Vancouver CMA,Naturalized Citizens and Permanent Residents,2006,71.9 +Vancouver CMA,Naturalized Citizens and Permanent Residents,2011,71.9 +Vancouver CMA,Naturalized Citizens and Permanent Residents,2016,69.4 +Vancouver CMA,Naturalized Citizens and Permanent Residents,2021,69.2 +Vancouver CMA,Non-permanent Residents,2006,35.7 +Vancouver CMA,Non-permanent Residents,2011,36.3 +Vancouver CMA,Non-permanent Residents,2016,33.4 +Vancouver CMA,Non-permanent Residents,2021,24.7 +Toronto CMA,Canadian-born,2006,77.0 +Toronto CMA,Canadian-born,2011,76.9 +Toronto CMA,Canadian-born,2016,74.3 +Toronto CMA,Canadian-born,2021,73.9 +Toronto CMA,Naturalized Citizens and Permanent Residents,2006,71.7 +Toronto CMA,Naturalized Citizens and Permanent Residents,2011,73.1 +Toronto CMA,Naturalized Citizens and Permanent Residents,2016,72.7 +Toronto CMA,Naturalized Citizens and Permanent Residents,2021,71.2 +Toronto CMA,Non-permanent Residents,2006,33.7 +Toronto CMA,Non-permanent Residents,2011,32.0 +Toronto CMA,Non-permanent Residents,2016,33.4 +Toronto CMA,Non-permanent Residents,2021,21.7 +Montreal CMA,Canadian-born,2006,65.1 +Montreal CMA,Canadian-born,2011,66.7 +Montreal CMA,Canadian-born,2016,66.8 +Montreal CMA,Canadian-born,2021,66.1 +Montreal CMA,Naturalized Citizens and Permanent Residents,2006,51.9 +Montreal CMA,Naturalized Citizens and Permanent Residents,2011,53.2 +Montreal CMA,Naturalized Citizens and Permanent Residents,2016,53.2 +Montreal CMA,Naturalized Citizens and Permanent Residents,2021,55.8 +Montreal CMA,Non-permanent Residents,2006,19.3 +Montreal CMA,Non-permanent Residents,2011,15.7 +Montreal CMA,Non-permanent Residents,2016,13.4 +Montreal CMA,Non-permanent Residents,2021,16.6 +Edmonton CMA,Canadian-born,2006,75.1 +Edmonton CMA,Canadian-born,2011,76.6 +Edmonton CMA,Canadian-born,2016,75.0 +Edmonton CMA,Canadian-born,2021,72.5 +Edmonton CMA,Naturalized Citizens and Permanent Residents,2006,76.1 +Edmonton CMA,Naturalized Citizens and Permanent Residents,2011,73.7 +Edmonton CMA,Naturalized Citizens and Permanent Residents,2016,74.5 +Edmonton CMA,Naturalized Citizens and Permanent Residents,2021,70.9 +Edmonton CMA,Non-permanent Residents,2006,31.5 +Edmonton CMA,Non-permanent Residents,2011,31.1 +Edmonton CMA,Non-permanent Residents,2016,29.0 +Edmonton CMA,Non-permanent Residents,2021,29.0 diff --git a/posts/foreign-buyer-tax-vancouver-toronto/data/policy-timeline.csv b/posts/foreign-buyer-tax-vancouver-toronto/data/policy-timeline.csv new file mode 100644 index 0000000..3e646a1 --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/data/policy-timeline.csv @@ -0,0 +1,11 @@ +row,date,label +Ontario,2017-04-20,FBT 15% (Greater Golden Horseshoe) +Ontario,2022-03-30,FBT 20% (province-wide) +Ontario,2022-10-25,FBT 25% (province-wide) +British Columbia,2015-06-01,British Columbia starts collecting buyer citizenship data +British Columbia,2016-08-02,FBT 15% (Metro Vancouver) +British Columbia,2018-02-20,FBT to 20% (Five regions) + Speculation and Vacancy Tax introduced +British Columbia,2020-11-30,Land Owner Transparency Registry launched +Federal,2019-10-01,First time homebuyer shared equity incentive launched +Federal,2022-01-01,Vacant foreign owned homes tax 1% +Federal,2023-01-01,Two year ban on foreign homebuyers diff --git a/posts/foreign-buyer-tax-vancouver-toronto/data/rent-gap.csv b/posts/foreign-buyer-tax-vancouver-toronto/data/rent-gap.csv new file mode 100644 index 0000000..e65c9f1 --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/data/rent-gap.csv @@ -0,0 +1,25 @@ +region,year,coefficient,pctGap,significance,pctLow,pctHigh +B.C.,2010,0.052,5.3,,-0.5,11.4 +B.C.,2011,0.038,3.9,,-0.3,8.5 +B.C.,2012,0.032,3.3,,-0.8,7.8 +B.C.,2013,0.015,1.5,,-2.2,5.1 +B.C.,2014,0.004,0.4,,-2.5,3.0 +B.C.,2015,0,0,,-1.8,2.0 +B.C.,2016,0.022,2.2,,-1.0,5.7 +B.C.,2017,0.083,8.7,**,2.5,15.0 +B.C.,2018,0.088,9.2,***,3.6,15.0 +B.C.,2019,0.120,12.7,***,6.0,20.1 +B.C.,2020,0.087,9.1,*,1.2,17.7 +B.C.,2021,0.094,9.9,*,1.0,18.9 +Ontario,2010,0.005,0.5,,-2.3,3.3 +Ontario,2011,0.000,0.0,,-2.7,2.7 +Ontario,2012,-0.005,-0.5,,-2.9,1.9 +Ontario,2013,-0.006,-0.6,,-2.9,1.7 +Ontario,2014,-0.003,-0.3,,-2.2,1.6 +Ontario,2015,-0.015,-1.5,,-3.8,0.8 +Ontario,2016,0,0,,-0.6,0.6 +Ontario,2017,0.001,0.1,,-2.0,2.2 +Ontario,2018,0.022,2.2,*,0.0,4.4 +Ontario,2019,0.025,2.5,,-0.3,5.3 +Ontario,2020,0.019,1.9,,-1.2,5.0 +Ontario,2021,0.022,2.2,,-1.5,5.9 diff --git a/posts/foreign-buyer-tax-vancouver-toronto/meta.json b/posts/foreign-buyer-tax-vancouver-toronto/meta.json new file mode 100644 index 0000000..de648d6 --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/meta.json @@ -0,0 +1,47 @@ +{ + "title": "Impacts of foreign buyer taxes on housing markets", + "type": "post", + "summary": "How rents and home ownership in the Toronto and Vancouver areas changed after their foreign buyer tax policies took effect.", + "authors": [ + "Jeff Allen" + ], + "published": "2026-09-08", + "license": "CC-BY-4.0", + "cardImage": "rent-gap", + "geography": [ + "Canada", + "British Columbia", + "Ontario", + "Vancouver", + "Toronto" + ], + "tags": [ + "urban", + "housing", + "tax", + "policy" + ], + "documents": [ + { + "relation": "from", + "type": "paper", + "authors": [ + "Song, T." + ], + "year": 2025, + "title": "Anti-immigration and the politics of housing affordability: Examining the evolution and housing market impacts of Foreign Buyer Taxes in British Columbia and Ontario", + "publication": "Journal of Urban Affairs", + "link": "https://doi.org/10.1080/07352166.2025.2570754", + "linkType": "DOI" + }, + { + "relation": "for", + "type": "brief", + "authors": [ + "School of Cities" + ], + "year": 2026, + "title": "Foreign buyer taxes and the rental affordability gap" + } + ] +} \ No newline at end of file diff --git a/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/embed.json b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/embed.json new file mode 100644 index 0000000..38123bd --- /dev/null +++ b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/embed.json @@ -0,0 +1,14 @@ +{ + "homeownership-by-citizenship": { + "width": 1080, + "height": 682 + }, + "policy-timeline": { + "width": 1080, + "height": 677 + }, + "rent-gap": { + "width": 1080, + "height": 682 + } +} diff --git a/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/homeownership-by-citizenship-1080.png b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/homeownership-by-citizenship-1080.png new file mode 100644 index 0000000..55d475b Binary files /dev/null and b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/homeownership-by-citizenship-1080.png differ diff --git a/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/homeownership-by-citizenship-2160.png b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/homeownership-by-citizenship-2160.png new file mode 100644 index 0000000..364b7cf Binary files /dev/null and b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/homeownership-by-citizenship-2160.png differ diff --git a/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/policy-timeline-2160.png b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/policy-timeline-2160.png new file mode 100644 index 0000000..91a3e94 Binary files /dev/null and b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/policy-timeline-2160.png differ diff --git a/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/rent-gap-1080.png b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/rent-gap-1080.png new file mode 100644 index 0000000..be487cd Binary files /dev/null and b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/rent-gap-1080.png differ diff --git a/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/rent-gap-2160.png b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/rent-gap-2160.png new file mode 100644 index 0000000..f5eaafa Binary files /dev/null and b/posts/foreign-buyer-tax-vancouver-toronto/published/2026-09-08/rent-gap-2160.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/+page.svelte b/posts/spatial-access-to-public-libraries-toronto/+page.svelte new file mode 100644 index 0000000..14d3537 --- /dev/null +++ b/posts/spatial-access-to-public-libraries-toronto/+page.svelte @@ -0,0 +1,28 @@ + + +
+ +

+ Placeholder text — replace with the post's introduction. +

+ +
+ + + + + + + + + + + + diff --git a/posts/spatial-access-to-public-libraries-toronto/charts.js b/posts/spatial-access-to-public-libraries-toronto/charts.js new file mode 100644 index 0000000..ba7e85e --- /dev/null +++ b/posts/spatial-access-to-public-libraries-toronto/charts.js @@ -0,0 +1,42 @@ +import { resolveDownloads } from '$lib/downloads.js'; + +const dataUrls = import.meta.glob('./data/*', { eager: true, query: '?url', import: 'default' }); +const pngUrls = import.meta.glob('./published/*/*.png', { + eager: true, + query: '?url', + import: 'default' +}); +const embedMeta = import.meta.glob('./published/*/embed.json', { + eager: true, + import: 'default' +}); + +const charts = { + 'spatial-access-map': { + title: 'Spatial access to public libraries in Toronto', + subtitle: 'Shortest travel time to the nearest library by public transit and walking', + source: 'Toronto Public Libraries, City of Toronto, Toronto Transit Commission (2026)', + alt: 'Two maps of Toronto showing travel time to the nearest public library in three bands, under 15 minutes, 15 to 30 minutes, and 30 minutes or more, by walking and by public transit, with library locations and major transit lines overlaid.', + widths: [1080], + graphicVerb: 'created' + }, + 'demographics-grid': { + title: "Six demographic groups across Toronto's census tracts, mapped alongside library locations", + subtitle: 'Each map shades census tracts by population share: visible minority, low income, recent and first-generation immigrants, seniors, and children', + source: 'Census of Population, Statistics Canada (2021); Toronto Public Libraries', + alt: 'Six small maps of Toronto census tracts, each shaded by a different demographic measure — visible minority, low income, recent immigrants, first-generation immigrants, seniors, and children — with public library locations marked as dots on every map.', + widths: [1080], + graphicVerb: 'created' + }, + 'travel-time-boxplot': { + title: 'Travel time to the nearest library by demographic group', + subtitle: "Every group's median is at or slightly above the citywide median", + source: 'Census of Population, Statistics Canada (2021); Toronto Public Libraries', + alt: 'Two box-and-whisker charts, walking and public transit, comparing travel time to the nearest library across demographic groups and the overall population, one row per group, showing the 10th, 25th, 50th (median), 75th, and 90th percentiles.', + data: 'travel_time_percentiles.csv', + widths: [1080], + graphicVerb: 'created' + } +}; + +export default resolveDownloads(charts, { dataUrls, pngUrls, embedMeta }); diff --git a/posts/spatial-access-to-public-libraries-toronto/charts/demographics-grid.svelte b/posts/spatial-access-to-public-libraries-toronto/charts/demographics-grid.svelte new file mode 100644 index 0000000..19705f1 --- /dev/null +++ b/posts/spatial-access-to-public-libraries-toronto/charts/demographics-grid.svelte @@ -0,0 +1,145 @@ + + +
+ {#each PANELS as panel} +
+
{panel.label} (%)
+
+
+ {#each panel.colors as color} +
+ {/each} + {#each panel.breaks as brk, i} + {brk}% + {/each} +
+
+ + Public library +
+
+ + +
+ {/each} +
+ + diff --git a/posts/spatial-access-to-public-libraries-toronto/charts/spatial-access-map.svelte b/posts/spatial-access-to-public-libraries-toronto/charts/spatial-access-map.svelte new file mode 100644 index 0000000..9a5750e --- /dev/null +++ b/posts/spatial-access-to-public-libraries-toronto/charts/spatial-access-map.svelte @@ -0,0 +1,114 @@ + + +
+ + + {#each legends as legend} +
+
+ Travel time by {legend.modeText} to the nearest library (minutes) +
+
+
+ + {#each bandColors as color} +
+ {/each} + 15 + 30 +
+
+
+ {/each} +
+ + diff --git a/posts/spatial-access-to-public-libraries-toronto/charts/travel-time-boxplot.svelte b/posts/spatial-access-to-public-libraries-toronto/charts/travel-time-boxplot.svelte new file mode 100644 index 0000000..0e88c6d --- /dev/null +++ b/posts/spatial-access-to-public-libraries-toronto/charts/travel-time-boxplot.svelte @@ -0,0 +1,356 @@ + + +
+
+ {#each panels as panel} +
+
+ Median travel time by {panel.modeText} to the nearest library + (minutes) +
+ + {#each panel.gridlines as g} + + {/each} + {#each panel.ticks as tick} + {tick}{tick === panel.ticks[panel.ticks.length - 1] ? ' min' : ''} + {/each} + + {#each panel.rows as row, i} + {@const cy = rowY(i)} + {row.group} + + + + {#if row.p10Over} + + {:else} + + {/if} + + {#if row.p90Over} + + {:else} + + {/if} + + + + {row.p50.toFixed(1)} + {/each} + +
+ {/each} +
+ +
+ + 10th + 25th + Median + 75th + 90th + + + + + + + + + {#if anyOverflow} + + + + + extends past the chart's edge + + {/if} +
+
+ + diff --git a/posts/spatial-access-to-public-libraries-toronto/data/travel_time_percentiles.csv b/posts/spatial-access-to-public-libraries-toronto/data/travel_time_percentiles.csv new file mode 100644 index 0000000..9faa985 --- /dev/null +++ b/posts/spatial-access-to-public-libraries-toronto/data/travel_time_percentiles.csv @@ -0,0 +1,15 @@ +group,mode,p10,p25,p50,p75,p90 +Children (0-14),Transit,9.84,12.98,17.55,21.58,26.12 +Children (0-14),Walk,10.23,14.07,19.85,27.84,37.43 +First-gen. immigrants,Transit,9.69,13.09,17.75,21.96,26.35 +First-gen. immigrants,Walk,10.01,14.11,20.18,28.28,37.99 +Low income,Transit,9.58,12.76,17.42,21.64,26.18 +Low income,Walk,9.88,13.7,19.68,27.77,37.53 +Recent immigrants,Transit,9.37,12.78,17.43,21.43,25.76 +Recent immigrants,Walk,9.73,13.65,19.45,27.46,36.96 +Seniors (65+),Transit,9.74,12.89,17.65,22.04,26.61 +Seniors (65+),Walk,10.06,13.94,20.13,28.28,38.03 +Total population,Transit,9.52,12.65,17.18,21.43,26.16 +Total population,Walk,9.83,13.55,19.38,27.37,37.21 +Visible minority,Transit,9.82,13.38,17.93,22.12,26.43 +Visible minority,Walk,10.15,14.45,20.56,28.59,38.1 diff --git a/posts/spatial-access-to-public-libraries-toronto/images/demographics-children.png b/posts/spatial-access-to-public-libraries-toronto/images/demographics-children.png new file mode 100644 index 0000000..1ad57a1 Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/images/demographics-children.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/images/demographics-first-gen-immigrants.png b/posts/spatial-access-to-public-libraries-toronto/images/demographics-first-gen-immigrants.png new file mode 100644 index 0000000..af69d90 Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/images/demographics-first-gen-immigrants.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/images/demographics-low-income.png b/posts/spatial-access-to-public-libraries-toronto/images/demographics-low-income.png new file mode 100644 index 0000000..34f098a Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/images/demographics-low-income.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/images/demographics-recent-immigrants.png b/posts/spatial-access-to-public-libraries-toronto/images/demographics-recent-immigrants.png new file mode 100644 index 0000000..34af034 Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/images/demographics-recent-immigrants.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/images/demographics-seniors.png b/posts/spatial-access-to-public-libraries-toronto/images/demographics-seniors.png new file mode 100644 index 0000000..01fedbc Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/images/demographics-seniors.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/images/demographics-visible-minority.png b/posts/spatial-access-to-public-libraries-toronto/images/demographics-visible-minority.png new file mode 100644 index 0000000..db70555 Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/images/demographics-visible-minority.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/images/toronto-libraries-access-1080.png b/posts/spatial-access-to-public-libraries-toronto/images/toronto-libraries-access-1080.png new file mode 100644 index 0000000..4345b79 Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/images/toronto-libraries-access-1080.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/meta.json b/posts/spatial-access-to-public-libraries-toronto/meta.json new file mode 100644 index 0000000..ec425f4 --- /dev/null +++ b/posts/spatial-access-to-public-libraries-toronto/meta.json @@ -0,0 +1,22 @@ +{ + "title": "Public transit and pedestrian accessibility to public libraries in Toronto", + "type": "post", + "summary": "How long it takes to reach the nearest public library across Toronto, and whether travel times differ by demographic group.", + "authors": ["Polina Gorn", "Jeff Allen"], + "published": "2026-09-02", + "license": "CC-BY-4.0", + "geography": ["Toronto"], + "tags": ["urban", "public transit", "accessibility", "public libraries"], + "documents": [ + { + "relation": "from", + "type": "code", + "authors": ["Polina Gorn", "Jeff Allen"], + "year": 2026, + "title": "Spatial access to public libraries in Toronto", + "publication": "School of Cities", + "link": "https://github.com/schoolofcities/public-libraries", + "linkType": "GitHub" + } + ] +} diff --git a/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/demographics-grid-2160.png b/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/demographics-grid-2160.png new file mode 100644 index 0000000..5fbaf0c Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/demographics-grid-2160.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/embed.json b/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/embed.json new file mode 100644 index 0000000..19ea1e9 --- /dev/null +++ b/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/embed.json @@ -0,0 +1,14 @@ +{ + "demographics-grid": { + "width": 1080, + "height": 785 + }, + "spatial-access-map": { + "width": 1080, + "height": 1353 + }, + "travel-time-boxplot": { + "width": 1080, + "height": 596 + } +} diff --git a/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/spatial-access-map-2160.png b/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/spatial-access-map-2160.png new file mode 100644 index 0000000..24f7af1 Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/spatial-access-map-2160.png differ diff --git a/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/travel-time-boxplot-2160.png b/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/travel-time-boxplot-2160.png new file mode 100644 index 0000000..4f191d4 Binary files /dev/null and b/posts/spatial-access-to-public-libraries-toronto/published/2026-09-02/travel-time-boxplot-2160.png differ diff --git a/scripts/export-png.js b/scripts/export-png.js new file mode 100644 index 0000000..4e81ebb --- /dev/null +++ b/scripts/export-png.js @@ -0,0 +1,215 @@ +// Exports print-resolution PNGs for one post, one chart, or the whole site, +// screenshotting the (not-yet-built) bare figure route at each of a chart's +// declared widths, at 2x device scale for retina. +// +// Usage: +// npm run export-pngs -- --post [--chart ] [--overwrite] +// npm run export-pngs -- --all [--overwrite] +// +// Writes into posts/{slug}/published/{lastUpdated ?? published}/{chart}-{width}.png +// and never overwrites an existing file unless --overwrite is passed. +// +// Each screenshot is quantised to a palette on the way out (see lib/png.js), +// which takes roughly 44% off a typical chart export here. There is no flag to +// skip it: these files are reproducible — delete one and re-run — so there is +// nothing to lose, and lib/png.js rejects its own work and leaves the original +// alone whenever quantising would shift a colour that matters. + +import { chromium } from 'playwright'; +import { createServer, build, preview } from 'vite'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { detectEngine, formatBytes, quantizePng } from './lib/png.js'; +import { lastUpdated } from '../src/lib/meta.js'; + +const rootDir = fileURLToPath(new URL('..', import.meta.url)); +const SCALE = 2; + +function parseArgs(argv) { + const args = { post: null, chart: null, all: false, overwrite: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--post') args.post = argv[++i]; + else if (arg === '--chart') args.chart = argv[++i]; + else if (arg === '--all') args.all = true; + else if (arg === '--overwrite') args.overwrite = true; + } + return args; +} + +function usageError(message) { + console.error(`Error: ${message}\n`); + console.error('Usage:'); + console.error(' npm run export-pngs -- --post [--chart ] [--overwrite]'); + console.error(' npm run export-pngs -- --all [--overwrite]'); + process.exit(1); +} + +/** + * Version folders a post is allowed to have: the date it was published, plus + * one for every changelog entry. Anything else is left over from a date that + * was edited before the post went live, and is removed by pruneVersions below. + */ +function validVersions(meta) { + return new Set([meta.published, ...(meta.changelog ?? []).map((c) => c.date)].filter(Boolean)); +} + +/** + * Removes version folders that no longer correspond to anything in meta.json. + * + * Editing `published` means the post was never live under the old date, so its + * exports were a draft and are deleted. Adding a changelog entry does the + * opposite: the older folder stays, because it records what the post looked + * like when it was published under that date. + */ +function pruneVersions(slug, meta) { + const dir = path.join(rootDir, 'posts', slug, 'published'); + if (!fs.existsSync(dir)) return; + + const valid = validVersions(meta); + for (const name of fs.readdirSync(dir)) { + if (valid.has(name)) continue; + fs.rmSync(path.join(dir, name), { recursive: true, force: true }); + console.log(`removed stale version: posts/${slug}/published/${name}/ (not in meta.json)`); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (!args.all && !args.post) { + usageError('specify --post or --all'); + } + if (args.chart && !args.post) { + usageError('--chart requires --post'); + } + + // Load post/chart metadata via Vite's SSR module graph (not a plain + // `import`, since posts.js uses import.meta.glob, a Vite-only feature). + // Cheap, so do it before the expensive build step to fail fast on typos. + const devServer = await createServer({ + server: { middlewareMode: true }, + optimizeDeps: { noDiscovery: true } + }); + const { posts, chartEntries } = await devServer.ssrLoadModule('/src/lib/posts.js'); + await devServer.close(); + + let targets = chartEntries; + if (args.post) targets = targets.filter((e) => e.slug === args.post); + if (args.chart) targets = targets.filter((e) => e.chart === args.chart); + + if (targets.length === 0) { + usageError( + `no matching charts found for --post ${args.post ?? '(any)'} --chart ${args.chart ?? '(any)'}` + ); + } + + console.log(`Building the site (needed so the figure routes reflect current source)...`); + await build({ logLevel: 'warn' }); + + // Say which quantiser is in play rather than letting the output silently + // differ between machines depending on what happens to be installed. + const engine = await detectEngine(); + if (engine) { + console.log(`Quantising with ${engine}.`); + } else { + console.warn( + 'No quantiser found — exports will be written unoptimised.\n' + + ' Install one with: sudo apt install pngquant (or: brew install pngquant)' + ); + } + + console.log(`Exporting ${targets.length} chart(s)...`); + + const previewServer = await preview({ preview: { port: 4173 } }); + const baseUrl = previewServer.resolvedUrls.local[0]; + const browser = await chromium.launch(); + + // chart id -> rendered height at its embed width, collected as we go and + // written to embed.json per version folder. ChartFrame needs this to build an + // iframe snippet without measuring in the browser, which is what let the + // Share dialog stop depending on JavaScript. A layout engine is the only + // thing that can answer it — some charts wrap to a new row rather than + // scaling as a fixed ratio — and this script already has one running. + // Keyed by output folder, since one run can span several posts and versions. + const embedHeights = new Map(); + + // Once per post, before writing anything into it. + for (const slug of new Set(targets.map((t) => t.slug))) { + pruneVersions(slug, posts[slug].meta); + } + + for (const { slug, chart } of targets) { + const { meta, charts } = posts[slug]; + const chartMeta = charts[chart]; + const widths = chartMeta.widths ?? []; + const date = lastUpdated(meta.changelog) ?? meta.published; + const outDir = path.join(rootDir, 'posts', slug, 'published', date); + fs.mkdirSync(outDir, { recursive: true }); + + // The embed uses the largest declared width, matching ChartFrame. + const embedWidth = Math.max(...widths); + + for (const width of widths) { + // Named by the file's real pixel width, not the design width it was laid + // out at — `widths: [1080]` rendered at SCALE=2 is a 2160px file, and + // that is the number the download label and filename both report. + const outPath = path.join(outDir, `${chart}-${width * SCALE}.png`); + const relPath = path.relative(rootDir, outPath); + + if (fs.existsSync(outPath) && !args.overwrite) { + console.log(`skip (exists): ${relPath}`); + continue; + } + + const context = await browser.newContext({ + viewport: { width, height: 800 }, + deviceScaleFactor: SCALE + }); + const page = await context.newPage(); + + const url = `${baseUrl}posts/${slug}/figures/${chart}/`; + await page.goto(url, { waitUntil: 'networkidle' }); + + // Measure before hiding the buttons: an embedded copy shows them, so a + // height taken with them hidden would leave the iframe short. + if (width === embedWidth) { + const box = await page.locator('.chart-frame').boundingBox(); + if (!embedHeights.has(outDir)) embedHeights.set(outDir, {}); + embedHeights.get(outDir)[chart] = { width: embedWidth, height: Math.ceil(box.height) }; + } + + // The figure route renders Read more/Download/Share buttons for the + // live embed page, but a flat PNG export has nothing for them to do. + await page.addStyleTag({ content: '.chart-buttons { display: none !important; }' }); + await page.locator('.chart-frame').screenshot({ path: outPath }); + await context.close(); + + const result = await quantizePng(outPath); + const note = result.applied + ? `${formatBytes(result.before)} -> ${formatBytes(result.after)}, palette` + : `${formatBytes(result.before)}, kept as-is: ${result.reason}`; + + console.log(`${args.overwrite ? 'overwrote' : 'wrote'}: ${relPath} (${note})`); + } + } + + await browser.close(); + await previewServer.close(); + + // Merged rather than replaced, so exporting a single chart doesn't drop the + // heights recorded for its siblings in the same version. + for (const [dir, heights] of embedHeights) { + const file = path.join(dir, 'embed.json'); + const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; + const merged = Object.fromEntries(Object.entries({ ...existing, ...heights }).sort()); + fs.writeFileSync(file, `${JSON.stringify(merged, null, '\t')}\n`); + console.log(`wrote: ${path.relative(rootDir, file)}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/lib/png.js b/scripts/lib/png.js new file mode 100644 index 0000000..49ab7cf --- /dev/null +++ b/scripts/lib/png.js @@ -0,0 +1,238 @@ +// Shared PNG post-processing, used by export-png.js (and, later, by the +// optimizer for hand-made images and by validate.js's build gate). +// +// Quantising a PNG to a palette is the single biggest size win available on a +// flat-colour map or chart: it drops the file from 4 bytes/pixel to 1 before +// compression starts, and — more importantly — collapses anti-aliasing fringes +// onto a handful of indices, which is what lets DEFLATE find repeats at all. +// Measured on this repo's chart exports it removes ~44%, and on the library map +// it took 6.14 MB to 2.14 MB. +// +// It is also lossy, and what it loses first is thin, saturated, low-area +// features. A 1px transit line or a small dot covers few pixels, so a palette +// chosen by pixel population spends no entries on it. Dropping the library map +// to 32 colours turned its red transit lines grey — while *lowering* mean +// absolute error, because a few important pixels moving a long way is invisible +// to an average taken over five million of them. That is why the guard below +// measures per-colour drift on the colours that occupy real area, not MAE. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const run = promisify(execFile); + +// Colours covering at least this share of the image are treated as design +// colours that must survive. 0.05% is below the smallest deliberate element +// measured here (the library map's transit lines, at 0.37%) with room to spare. +const SIGNIFICANT_SHARE = 0.0005; + +// Sum of |dR|+|dG|+|dB| (0-765) that a design colour is allowed to move. +// Calibrated against the library map, where the visible failure point sits +// well above the safe range: 256 colours drifts 12, 128 drifts 32, 64 drifts +// 97, and 32 drifts 137 (visibly wrong). 24 leaves headroom over the former +// without reaching the latter. +const MAX_DRIFT = 24; + +// Guard against a pathological source where nearly every colour clears the +// share threshold; the dominant colours are the ones that matter. +const MAX_SIGNIFICANT = 64; + +let enginePromise; + +/** + * Which quantiser is available, preferring pngquant: it weights colours + * perceptually rather than purely by pixel count, so it protects exactly the + * thin features the guard below is watching. ImageMagick is the fallback + * because it ships preinstalled on GitHub's runners and on most desktops. + */ +export function detectEngine() { + enginePromise ??= (async () => { + for (const [engine, probe] of [ + ['pngquant', ['--version']], + ['convert', ['--version']] + ]) { + try { + await run(engine, probe); + return engine; + } catch { + // not installed; try the next one + } + } + return null; + })(); + return enginePromise; +} + +// A 2160x2400 image is ~15 MB of raw RGB; leave generous headroom above that. +const RAW_BUFFER = 512 * 1024 * 1024; + +/** + * Exact colour histogram as a Map of packed 0xRRGGBB -> pixel count. + * + * Counted from raw pixels rather than ImageMagick's own `histogram:` output, + * which silently approximates above a few thousand distinct colours — it + * reduced a 111k-colour map to 256 entries in testing, which would have meant + * measuring drift against an approximation of the thing being checked. + */ +async function histogram(file) { + const { stdout } = await run('convert', [file, '-depth', '8', 'rgb:-'], { + encoding: 'buffer', + maxBuffer: RAW_BUFFER + }); + + const counts = new Map(); + for (let i = 0; i + 2 < stdout.length; i += 3) { + const key = (stdout[i] << 16) | (stdout[i + 1] << 8) | stdout[i + 2]; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return { counts, total: Math.floor(stdout.length / 3) }; +} + +function drift(a, b) { + return ( + Math.abs((a >> 16) - (b >> 16)) + + Math.abs(((a >> 8) & 255) - ((b >> 8) & 255)) + + Math.abs((a & 255) - (b & 255)) + ); +} + +function rgb(key) { + return [key >> 16, (key >> 8) & 255, key & 255]; +} + +/** + * Worst distance any significant source colour had to travel to reach its + * nearest neighbour in the quantised image's palette. This is the number that + * separated the good quantisations from the bad ones in testing; mean error + * did not. + */ +function worstDrift(source, quantised) { + const palette = [...quantised.counts.keys()]; + + const significant = [...source.counts.entries()] + .filter(([, count]) => count / source.total >= SIGNIFICANT_SHARE) + .sort((a, b) => b[1] - a[1]) + .slice(0, MAX_SIGNIFICANT) + .map(([key]) => key); + + let worst = 0; + let culprit = null; + for (const colour of significant) { + // An exact match is by far the common case, so skip the scan for it. + if (quantised.counts.has(colour)) continue; + + let nearest = Infinity; + for (const entry of palette) nearest = Math.min(nearest, drift(colour, entry)); + if (nearest > worst) { + worst = nearest; + culprit = colour; + } + } + return { worst, culprit, checked: significant.length }; +} + +async function writeQuantised(engine, input, output, colours) { + if (engine === 'pngquant') { + // --quality's lower bound makes pngquant refuse (non-zero exit, no output) + // rather than write something bad, which is a second guard underneath ours. + await run('pngquant', [ + `--quality=65-95`, + '--strip', + '--force', + '--output', + output, + '--', + input + ]); + } else { + await run('convert', [input, '-strip', '-colors', String(colours), output]); + } +} + +/** + * Header facts about a PNG, read straight from IHDR with no external tool — + * validate.js runs on every contributor's machine and in CI, so its check has + * to work with nothing installed. + */ +export function inspectPng(file) { + const buf = fs.readFileSync(file); + if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error(`not a PNG: ${file}`); + + const width = buf.readUInt32BE(16); + const height = buf.readUInt32BE(20); + const colourType = buf[25]; + + return { + width, + height, + colourType, + // 3 is the palette type; 0/2/4/6 all store full colour per pixel. + isPalette: colourType === 3, + bytes: buf.length, + bytesPerPixel: buf.length / (width * height) + }; +} + +/** + * Quantise `file`, but only if every significant colour survives and the + * result is actually smaller. Writes over `file` unless `out` names somewhere + * else. Returns what happened; never throws for a rejected quantisation, since + * keeping the original is a valid outcome. + */ +export async function quantizePng(file, { colours = 256, out } = {}) { + const engine = await detectEngine(); + const before = fs.statSync(file).size; + + if (!engine) { + return { applied: false, before, reason: 'no quantiser found (install pngquant)' }; + } + + const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'png-')), path.basename(file)); + + try { + try { + await writeQuantised(engine, file, tmp, colours); + } catch { + // pngquant exits non-zero when it can't meet --quality; treat any + // engine failure the same way, as "this image should stay as it is". + return { applied: false, before, engine, reason: 'quantiser declined' }; + } + + const after = fs.statSync(tmp).size; + if (after >= before) { + return { applied: false, before, after, engine, reason: 'no size win' }; + } + + // If the check itself can't run, keep the original — an unverified + // quantisation is not one to apply silently. + let worst, culprit, checked; + try { + ({ worst, culprit, checked } = worstDrift(await histogram(file), await histogram(tmp))); + } catch (err) { + return { applied: false, before, after, engine, reason: `could not verify (${err.code ?? err.message})` }; + } + + if (worst > MAX_DRIFT) { + const colour = culprit === null ? 'a colour' : `rgb(${rgb(culprit).join(',')})`; + return { + applied: false, + before, + after, + engine, + reason: `${colour} would shift by ${worst} (limit ${MAX_DRIFT})` + }; + } + + fs.copyFileSync(tmp, out ?? file); + return { applied: true, before, after, engine, drift: worst, checked, path: out ?? file }; + } finally { + fs.rmSync(path.dirname(tmp), { recursive: true, force: true }); + } +} + +export function formatBytes(n) { + return n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.round(n / 1024)} KB`; +} diff --git a/scripts/optimize-png.js b/scripts/optimize-png.js new file mode 100644 index 0000000..851c8a5 --- /dev/null +++ b/scripts/optimize-png.js @@ -0,0 +1,202 @@ +// Quantises hand-made PNGs — the base images that charts draw on top of, made +// in QGIS/Inkscape/GIMP and dropped into posts/{slug}/images/. +// +// Usage: +// npm run optimize-pngs -- --post [--accept] +// npm run optimize-pngs -- --path [--accept] +// +// Two passes on purpose. The first writes a candidate next to each original +// ({name}-opt.png) and changes nothing else; you open it and check the parts +// quantising puts at risk — thin lines, small dots, small text. The second, +// with --accept, moves the candidate you just looked at over the original and +// removes the candidate. +// +// Unlike the chart exports in export-png.js, these files can't be regenerated +// from this repo — the QGIS project lives in the pipeline repo — so nothing is +// overwritten without a person having seen the replacement first. +// +// There is deliberately no --all. Scoping to one post keeps the candidates to +// a handful you'll actually look at, rather than dozens across the archive +// that get accepted unread. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { detectEngine, formatBytes, inspectPng, quantizePng } from './lib/png.js'; + +const rootDir = fileURLToPath(new URL('..', import.meta.url)); +const SUFFIX = '-opt'; + +function parseArgs(argv) { + const args = { post: null, path: null, accept: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--post') args.post = argv[++i]; + else if (arg === '--path') args.path = argv[++i]; + else if (arg === '--accept') args.accept = true; + } + return args; +} + +function usageError(message) { + console.error(`Error: ${message}\n`); + console.error('Usage:'); + console.error(' npm run optimize-pngs -- --post [--accept]'); + console.error(' npm run optimize-pngs -- --path [--accept]'); + process.exit(1); +} + +function candidateFor(file) { + const dir = path.dirname(file); + return path.join(dir, `${path.basename(file, '.png')}${SUFFIX}.png`); +} + +/** Every .png under `target`, ignoring the candidates this script writes. */ +function collect(target) { + const stat = fs.statSync(target); + if (stat.isFile()) return target.endsWith('.png') ? [target] : []; + + return fs + .readdirSync(target, { withFileTypes: true }) + .flatMap((entry) => { + const full = path.join(target, entry.name); + if (entry.isDirectory()) return collect(full); + return entry.name.endsWith('.png') ? [full] : []; + }) + .filter((f) => !path.basename(f, '.png').endsWith(SUFFIX)) + .sort(); +} + +function resolveTarget(args) { + if (args.post) { + const dir = path.join(rootDir, 'posts', args.post, 'images'); + if (!fs.existsSync(dir)) { + usageError(`no images folder for post "${args.post}" (looked in posts/${args.post}/images)`); + } + return dir; + } + + const target = path.resolve(rootDir, args.path); + if (!fs.existsSync(target)) usageError(`no such file or directory: ${args.path}`); + return target; +} + +function rel(file) { + const relative = path.relative(rootDir, file); + // --path can point outside the repo, where a relative path is just noise. + return relative.startsWith('..') ? file : relative; +} + +/** Move each reviewed candidate over its original, keeping the original's name + * so nothing that imports it has to change. */ +function accept(files) { + let accepted = 0; + let saved = 0; + + for (const file of files) { + const candidate = candidateFor(file); + if (!fs.existsSync(candidate)) continue; + + const before = fs.statSync(file).size; + const after = fs.statSync(candidate).size; + fs.renameSync(candidate, file); + accepted++; + saved += before - after; + + console.log(`accepted: ${rel(file)} (${formatBytes(before)} -> ${formatBytes(after)})`); + } + + if (accepted === 0) { + console.log('No candidates found. Run without --accept first, then review them.'); + return; + } + console.log(`\n${accepted} file(s) accepted, ${formatBytes(saved)} saved.`); +} + +/** Write a candidate beside each file that would benefit, and report. */ +async function review(files) { + let candidates = 0; + let skipped = 0; + let rejected = 0; + + for (const file of files) { + const info = inspectPng(file); + + if (info.isPalette) { + skipped++; + continue; + } + + const candidate = candidateFor(file); + const result = await quantizePng(file, { out: candidate }); + + if (!result.applied) { + rejected++; + console.log(` ${rel(file)}`); + console.log(` ${formatBytes(info.bytes)} — left alone: ${result.reason}\n`); + continue; + } + + candidates++; + const pct = Math.round((100 * result.after) / result.before); + // `checked` is how many colours covered enough of the image to be worth + // verifying. Zero means the guard had nothing to test — true of photos and + // noise, where every colour is a sliver — so the review matters more, not less. + const verdict = + result.checked === 0 + ? 'no colour covers enough area to verify — check this one closely' + : `worst shift ${result.drift} across ${result.checked} significant colour(s)`; + + console.log(` ${rel(file)}`); + console.log( + ` ${formatBytes(result.before)} -> ${formatBytes(result.after)} (${pct}%), ${verdict}` + ); + console.log(` candidate: ${path.basename(candidate)}\n`); + } + + if (skipped) console.log(`${skipped} file(s) already quantised, skipped.`); + if (rejected) console.log(`${rejected} file(s) left as full colour (see reasons above).`); + + if (candidates === 0) { + console.log('Nothing to review.'); + return; + } + + console.log( + `\n${candidates} candidate(s) written. Open them and check the thin lines,\n` + + 'small dots, and small text — those are what quantising loses first.\n' + + 'Then re-run the same command with --accept.' + ); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + if (!args.post && !args.path) usageError('specify --post or --path '); + if (args.post && args.path) usageError('use --post or --path, not both'); + + const target = resolveTarget(args); + const files = collect(target); + + if (files.length === 0) usageError(`no PNGs found in ${rel(target)}`); + + if (args.accept) { + accept(files); + return; + } + + const engine = await detectEngine(); + if (!engine) { + console.error('No quantiser found.'); + console.error(' Install one with: sudo apt install pngquant (or: brew install pngquant)'); + process.exit(1); + } + + console.log(`Quantising with ${engine}. Reviewing ${files.length} file(s) in ${rel(target)}:\n`); + await review(files); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/strip-css.js b/scripts/strip-css.js new file mode 100644 index 0000000..a6c0a2e --- /dev/null +++ b/scripts/strip-css.js @@ -0,0 +1,123 @@ +// Removes tags a prerendered page doesn't actually need. +// +// Usage: +// node scripts/strip-css.js [--dry-run] +// +// Why they're there in the first place: one route (posts/[slug]/figures/[chart]) +// serves every chart on the site, so SvelteKit lists every chart's stylesheet as +// a possible dependency of that route. It has to — at build time it can't know +// which chart a given URL renders, and a missing stylesheet would mean a flash of +// unstyled content. The cost is that every page links every chart's CSS: fine at +// six charts, ~300 render-blocking requests at a hundred posts. +// +// Once the page is prerendered, though, we know exactly what it rendered. Svelte +// stamps each scoped rule with a hash (`.gridline.svelte-1jht5xl`) and puts that +// same class on the elements it styles, so a stylesheet whose hashes appear +// nowhere in the page's HTML cannot be affecting it, and its link can go. +// +// Client-side navigation is unaffected: the route's JS chunk carries its CSS +// dependencies and Vite's preload helper injects the stylesheet at runtime. With +// scripting off there is no client navigation, and every page is a fresh load +// carrying its own correct links. +// +// The safety rule is that anything unclassifiable is kept. A stylesheet with any +// unscoped selector may contain :global() rules, whose effects leave no hash in +// the markup — ChartFrame's shared dialog styles are exactly this — so those are +// never stripped. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = fileURLToPath(new URL('..', import.meta.url)); +const buildDir = path.join(rootDir, 'build'); + +const LINK = /]+rel="stylesheet"[^>]*>/g; +const HREF = /href="([^"]+)"/; + +/** Every .html file under `dir`. */ +function pages(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return pages(full); + return entry.name.endsWith('.html') ? [full] : []; + }); +} + +const analysed = new Map(); + +/** + * A stylesheet's scope hashes, and whether it's safe to strip at all. + * + * `strippable` is false when any selector carries no scope hash, since such a + * rule can match elements that show no trace of the stylesheet in the markup. + */ +function analyse(file) { + if (analysed.has(file)) return analysed.get(file); + + const body = fs.readFileSync(file, 'utf8'); + const hashes = [...new Set(body.match(/svelte-[a-z0-9]+/g) ?? [])]; + const selectors = [...body.matchAll(/([^{}]+)\{/g)].map((m) => m[1].trim()); + const unscoped = selectors.filter((s) => !s.includes('svelte-') && !s.startsWith('@')); + + const result = { hashes, strippable: hashes.length > 0 && unscoped.length === 0 }; + analysed.set(file, result); + return result; +} + +function main() { + const dryRun = process.argv.includes('--dry-run'); + + if (!fs.existsSync(buildDir)) { + console.error(`Error: no build directory at ${path.relative(rootDir, buildDir)} — run the build first.`); + process.exit(1); + } + + let pagesChanged = 0; + let linksRemoved = 0; + let bytesBefore = 0; + let bytesAfter = 0; + + for (const page of pages(buildDir).sort()) { + const html = fs.readFileSync(page, 'utf8'); + const dropped = []; + + const updated = html.replace(LINK, (tag) => { + const href = tag.match(HREF)?.[1]; + if (!href) return tag; + + const file = path.resolve(path.dirname(page), href); + if (!fs.existsSync(file)) return tag; // not ours to reason about + + const { hashes, strippable } = analyse(file); + const size = fs.statSync(file).size; + bytesBefore += size; + + // Keep anything unclassifiable, and anything the page actually uses. + if (!strippable || hashes.some((h) => html.includes(h))) { + bytesAfter += size; + return tag; + } + + dropped.push(path.basename(file).split('.')[0]); + return ''; + }); + + if (dropped.length === 0) continue; + + pagesChanged++; + linksRemoved += dropped.length; + if (!dryRun) fs.writeFileSync(page, updated); + + console.log(` ${path.relative(buildDir, page)}`); + console.log(` removed ${dropped.length}: ${dropped.join(', ')}`); + } + + const saved = bytesBefore - bytesAfter; + console.log( + `\n${dryRun ? '[dry run] would remove' : 'removed'} ${linksRemoved} stylesheet link(s) ` + + `across ${pagesChanged} page(s) — ${Math.round(saved / 1024)} KB of CSS no longer requested.` + ); +} + +main(); diff --git a/scripts/validate.js b/scripts/validate.js new file mode 100644 index 0000000..0da603b --- /dev/null +++ b/scripts/validate.js @@ -0,0 +1,368 @@ +// Checks every post before the site is built. +// +// Usage: +// node scripts/validate.js [--strict] +// +// It runs first in the `build` script, and the shell's `&&` means Vite never +// runs if this exits non-zero. So an error here stops the build, and in an +// automated publishing step that means the previous site stays online rather +// than being replaced by a broken one. +// +// Two levels: +// +// ERROR the post is wrong — a missing data file, a chart with no +// component, a date that isn't a date. Exits 1. +// WARNING the post is unfinished rather than wrong — a chart not yet +// exported, an image not yet quantized. Prints and continues, so a +// post can be built and previewed while it's still being written. +// +// --strict makes warnings exit 1 as well. Use it wherever the site is published +// from, so an unfinished post can be previewed locally but not published. +// +// Most of what's checked here fails silently at runtime by design: a data file +// that doesn't exist just drops a row from the Download menu, a `cardImage` +// naming nothing falls back to another chart. That is the right behaviour for a +// reader and the wrong behaviour for an author, which is why these checks exist. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import { inspectPng } from './lib/png.js'; +import { SITE_ORIGIN } from '../src/lib/site.js'; +import { RELATIONS } from '../src/lib/meta.js'; +import { GEOGRAPHY, TAGS } from '../src/lib/tags.js'; + +const rootDir = fileURLToPath(new URL('..', import.meta.url)); +const postsDir = path.join(rootDir, 'posts'); + +const REQUIRED_META = ['title', 'type', 'summary', 'authors', 'published', 'license']; +const REQUIRED_CHART = ['title', 'source', 'alt']; +const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +// Above this a single PNG is almost certainly a skipped optimisation step. The +// limit exists because git stores every revision of a binary file in full and +// forever, so an oversized image can't be taken back out later. For reference, +// the densest legitimate map here is 2.14 MB. +const MAX_PNG_BYTES = 5 * 1024 * 1024; + +const problems = []; +const error = (post, message) => problems.push({ level: 'error', post, message }); +const warn = (post, message) => problems.push({ level: 'warning', post, message }); + +/** A real calendar date, not just four digits and two dashes. */ +function isValidDate(value) { + if (typeof value !== 'string' || !ISO_DATE.test(value)) return false; + const date = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +function listDir(dir) { + return fs.existsSync(dir) ? fs.readdirSync(dir) : []; +} + +function checkMeta(slug, meta) { + for (const key of REQUIRED_META) { + if (meta[key] === undefined || meta[key] === '' || (Array.isArray(meta[key]) && !meta[key].length)) { + error(slug, `meta.json is missing required field "${key}"`); + } + } + + // Only posts live in this repo; explorers and data stories are built and + // hosted separately, so any other value means this file came from elsewhere. + if (meta.type !== undefined && meta.type !== 'post') { + error(slug, `meta.json has type "${meta.type}" — this repo only holds "post"`); + } + + if (meta.published !== undefined && !isValidDate(meta.published)) { + error(slug, `meta.json published "${meta.published}" is not a valid ISO date (YYYY-MM-DD)`); + } + + for (const entry of meta.changelog ?? []) { + if (!isValidDate(entry.date)) { + error(slug, `meta.json changelog date "${entry.date}" is not a valid ISO date (YYYY-MM-DD)`); + } + } + + for (const tag of meta.tags ?? []) { + if (!TAGS.includes(tag)) { + warn(slug, `tag "${tag}" is not in src/lib/tags.js — add it there, or use an existing term`); + } + } + + // A document with no recognised relation is dropped from the byline entirely, + // so a typo here removes a citation with no other symptom. + for (const doc of meta.documents ?? []) { + const declared = [doc.relation ?? []].flat(); + if (declared.length === 0) { + error(slug, `document "${doc.title ?? '(untitled)'}" has no relation — one of ${RELATIONS.join(', ')}`); + } + for (const relation of declared) { + if (!RELATIONS.includes(relation)) { + error(slug, `document "${doc.title ?? '(untitled)'}" has relation "${relation}" — must be one of ${RELATIONS.join(', ')}`); + } + } + } + + for (const place of meta.geography ?? []) { + if (!GEOGRAPHY.includes(place)) { + warn(slug, `geography "${place}" is not in src/lib/tags.js — check the spelling used by other posts`); + } + } +} + +function checkCharts(slug, charts) { + const dir = path.join(postsDir, slug); + const componentFiles = listDir(path.join(dir, 'charts')).filter((f) => f.endsWith('.svelte')); + const componentKeys = componentFiles.map((f) => path.basename(f, '.svelte')); + const dataFiles = listDir(path.join(dir, 'data')); + const referencedData = new Set(); + + for (const [key, chart] of Object.entries(charts)) { + if (!KEBAB.test(key)) { + error(slug, `chart id "${key}" must be kebab-case — it becomes a filename and a URL segment`); + } + + if (!componentKeys.includes(key)) { + error(slug, `charts.js has "${key}" but charts/${key}.svelte does not exist`); + } + + for (const field of REQUIRED_CHART) { + if (!chart[field]) error(slug, `chart "${key}" is missing "${field}"`); + } + + for (const file of [chart.data ?? []].flat().filter(Boolean)) { + referencedData.add(file); + if (!dataFiles.includes(file)) { + error(slug, `chart "${key}" lists data file "${file}", which is not in data/`); + } + } + + // Declaring widths is the statement that PNGs should exist; until + // `npm run export-pngs` has run, the Download menu offers no image. + if (chart.widths?.length && !chart.images?.length) { + warn(slug, `chart "${key}" declares widths but has no exported PNG — run npm run export-pngs`); + } + } + + for (const key of componentKeys) { + if (!(key in charts)) { + error(slug, `charts/${key}.svelte has no entry in charts.js, so it is never rendered`); + } + } + + // A data file can be used two independent ways: listed in charts.js `data` to + // be offered for download, or imported directly by a chart component to draw + // with. Either counts as used — the map layers behind demographics-grid are + // imported but deliberately not offered as a download. + const importedData = new Set(); + for (const file of componentFiles) { + const source = fs.readFileSync(path.join(dir, 'charts', file), 'utf8'); + for (const match of source.matchAll(/['"]\.\.\/data\/([^'"?]+)/g)) { + importedData.add(match[1]); + } + } + + for (const file of dataFiles) { + if (!referencedData.has(file) && !importedData.has(file)) { + warn(slug, `data/${file} is neither imported by a chart nor offered for download, but is still copied into the build`); + } + } + +} + +/** + * Every folder under published/ should correspond to a date in meta.json: the + * publication date, or a changelog entry. + * + * A folder matching neither is left over from a date that was edited after + * exporting. Downloads still resolve correctly — the newest date wins — but the + * orphaned files are copied into the build and served, unreachable. Nothing + * else surfaces this, since the files themselves are perfectly valid. + * + * `npm run export-pngs` removes these itself; this catches the case where + * meta.json changed and the export hasn't been re-run yet. + */ +function checkVersions(slug, meta) { + const valid = new Set([meta.published, ...(meta.changelog ?? []).map((c) => c.date)].filter(Boolean)); + + for (const name of listDir(path.join(postsDir, slug, 'published'))) { + if (!valid.has(name)) { + warn( + slug, + `published/${name}/ matches no date in meta.json — re-run npm run export-pngs to remove it` + ); + } + } +} + +function checkCardImage(slug, meta, charts) { + if (meta.cardImage && !(meta.cardImage in charts)) { + error(slug, `meta.json cardImage "${meta.cardImage}" is not a chart in charts.js`); + } +} + +function checkImages(slug) { + const dir = path.join(postsDir, slug); + + const files = [ + ...listDir(path.join(dir, 'images')).map((f) => ({ file: path.join(dir, 'images', f), rel: `images/${f}` })), + ...listDir(path.join(dir, 'published')).flatMap((date) => + listDir(path.join(dir, 'published', date)).map((f) => ({ + file: path.join(dir, 'published', date, f), + rel: `published/${date}/${f}`, + generated: true + })) + ) + ].filter(({ file }) => file.endsWith('.png')); + + for (const { file, rel, generated } of files) { + const info = inspectPng(file); + + if (info.bytes > MAX_PNG_BYTES) { + error( + slug, + `${rel} is ${(info.bytes / 1048576).toFixed(2)} MB, over the ${MAX_PNG_BYTES / 1048576} MB limit — ` + + `quantise it (npm run optimize-pngs) or export it smaller` + ); + } + + if (!info.isPalette) { + warn(slug, `${rel} is not quantised (stored as full colour) — see npm run optimize-pngs`); + } + + // Exports are named by their real pixel width, so a mismatch means the + // exporter and the file have diverged and the Download links will 404. + if (generated) { + const declared = Number(path.basename(rel, '.png').split('-').at(-1)); + if (Number.isFinite(declared) && declared !== info.width) { + error(slug, `${rel} is ${info.width}px wide but its name says ${declared}px`); + } + } + } +} + +/** meta.json is read directly first, so a syntax error names the file. */ +function readMetaFiles(slugs) { + const metas = {}; + for (const slug of slugs) { + const file = path.join(postsDir, slug, 'meta.json'); + if (!fs.existsSync(file)) { + error(slug, 'has no meta.json'); + continue; + } + try { + metas[slug] = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (err) { + error(slug, `meta.json is not valid JSON — ${err.message}`); + } + } + return metas; +} + +function report(strict) { + const errors = problems.filter((p) => p.level === 'error'); + const warnings = problems.filter((p) => p.level === 'warning'); + + for (const slug of [...new Set(problems.map((p) => p.post))].sort()) { + console.log(`\nposts/${slug}`); + for (const { level, message } of problems.filter((p) => p.post === slug)) { + console.log(` ${level === 'error' ? 'error ' : 'warning'} ${message}`); + } + } + + const failing = errors.length > 0 || (strict && warnings.length > 0); + console.log( + `\n${errors.length} error(s), ${warnings.length} warning(s)` + + (strict && warnings.length ? ' — warnings count as errors under --strict' : '') + ); + return failing; +} + +async function main() { + const strict = process.argv.includes('--strict'); + + const slugs = fs + .readdirSync(postsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + + if (slugs.length === 0) { + console.error('Error: no posts found in posts/'); + process.exit(1); + } + + const metas = readMetaFiles(slugs); + + // Stop here if any meta.json failed to parse. posts.js globs them all + // eagerly, so loading it below would throw Vite's own error over the top of + // the message above, which names the post and the syntax problem. + if (problems.some((p) => p.level === 'error')) { + report(strict); + process.exit(1); + } + + // charts.js uses import.meta.glob, which only Vite can resolve — the same + // approach export-png.js uses to read post metadata outside a build. + const server = await createServer({ + server: { middlewareMode: true }, + optimizeDeps: { noDiscovery: true }, + logLevel: 'error' + }); + let posts; + try { + ({ posts } = await server.ssrLoadModule('/src/lib/posts.js')); + } finally { + await server.close(); + } + + for (const slug of slugs) { + const meta = metas[slug]; + const charts = posts[slug]?.charts; + + if (!charts) { + error(slug, 'has no charts.js, or it exports nothing'); + continue; + } + if (!meta) continue; // already reported + + checkMeta(slug, meta); + checkCharts(slug, charts); + checkCardImage(slug, meta, charts); + checkVersions(slug, meta); + checkImages(slug); + } + + // Checked at the only moment it is at risk: when someone edits it for a domain + // move. Every absolute link on the site — canonical, og:url, og:image, the + // Share dialog, the embed snippet — is this value plus a path, so a missing + // scheme or a trailing slash produces links that read as plausible and are + // broken. Nothing else surfaces that. + // + // It cannot catch the value simply being out of date, which is the other way + // this goes wrong; only a person comparing it against the live domain can. + if (!SITE_ORIGIN) { + error('(site)', 'SITE_ORIGIN in src/lib/site.js is empty — every absolute link on the site depends on it'); + } else if (!/^https?:\/\/[^/\s]+$/.test(SITE_ORIGIN)) { + error( + '(site)', + `SITE_ORIGIN "${SITE_ORIGIN}" must be scheme and host with no trailing slash, ` + + `e.g. "https://schoolofcities.github.io"` + ); + } + + if (problems.length === 0) { + const chartCount = slugs.reduce((n, s) => n + Object.keys(posts[s]?.charts ?? {}).length, 0); + console.log(`${slugs.length} post(s), ${chartCount} chart(s) — no problems found.`); + return; + } + + if (report(strict)) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..1391f88 --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/ChartFrame.svelte b/src/lib/ChartFrame.svelte new file mode 100644 index 0000000..617cba3 --- /dev/null +++ b/src/lib/ChartFrame.svelte @@ -0,0 +1,554 @@ + + + + {#if sortedWidths} + {@html ``} + {/if} + + + + + +
+
+ {@render frameContent()} +
+
+ +{#if measureMounted} +
+ {@render frameContent()} +
+{/if} + +{#snippet frameContent()} + School of Cities + +

{meta.title}

+ + {#if meta.subtitle} +

{meta.subtitle}

+ {/if} + + + + +{/snippet} + +{#snippet dataIcon()} + + + + + + +{/snippet} + +{#snippet imageIcon()} + + + + + +{/snippet} + + +
+

Link to this chart

+
+ + +
+
+
+

Link to the full post

+
+ + +
+
+ {#if embedSnippet} +
+

+ Embed (you may need to adjust the height depending on your site's width). +

+
+ + +
+
+ {/if} +
+ + + + + + diff --git a/src/lib/CopyIcon.svelte b/src/lib/CopyIcon.svelte new file mode 100644 index 0000000..91c42af --- /dev/null +++ b/src/lib/CopyIcon.svelte @@ -0,0 +1,33 @@ + + +{#if copied} + + + +{:else} + + + +{/if} diff --git a/src/lib/Dialog.svelte b/src/lib/Dialog.svelte new file mode 100644 index 0000000..918a6b2 --- /dev/null +++ b/src/lib/Dialog.svelte @@ -0,0 +1,145 @@ + + + +
+
+

{title}

+ +
+ +
+
+ {@render children()} +
+
+ + diff --git a/src/lib/Footer.svelte b/src/lib/Footer.svelte new file mode 100644 index 0000000..afb2085 --- /dev/null +++ b/src/lib/Footer.svelte @@ -0,0 +1,61 @@ + + + + + diff --git a/src/lib/Header.svelte b/src/lib/Header.svelte new file mode 100644 index 0000000..dbfc02c --- /dev/null +++ b/src/lib/Header.svelte @@ -0,0 +1,63 @@ + + +
+ + {#if logoType === 'Blue'} + + {:else if logoType === 'White'} + + {:else if logoType === 'Black'} + + {:else if logoType === 'WhiteOnly'} + + {/if} + +
+ + diff --git a/src/lib/PostMeta.svelte b/src/lib/PostMeta.svelte new file mode 100644 index 0000000..b773202 --- /dev/null +++ b/src/lib/PostMeta.svelte @@ -0,0 +1,91 @@ + + + + {meta.title} + + + {#if noindex} + + {/if} + {#if url} + + {/if} + + + + + + {#if url} + + {/if} + {#if imageUrl} + + + {/if} + + + + + + {#if imageUrl} + + {/if} + + + {#if modified} + + {/if} + {#each meta.authors ?? [] as author} + + {/each} + + {@html ` + +
+
+

{meta.title}

+ + {#if meta.summary} +

{meta.summary}

+ {/if} + + +
+
+ + diff --git a/src/lib/downloads.js b/src/lib/downloads.js new file mode 100644 index 0000000..0c74a11 --- /dev/null +++ b/src/lib/downloads.js @@ -0,0 +1,73 @@ +// Turns each chart's `data` and `widths` declarations into the concrete list of +// files ChartFrame offers in its Download menu. +// +// The globs themselves stay in each post's charts.js — import.meta.glob only +// resolves patterns relative to the file it appears in — but the logic that +// interprets their results lives here so it isn't copied into every post. +// +// Nothing that doesn't exist on disk is ever offered. A chart whose `data` is +// absent (many maps have no single tabular file worth publishing) simply has no +// data rows, and a chart that was never exported has no image rows. That is why +// this filters on `url` rather than trusting the declaration. + +/** + * Newest URL for each exported PNG filename. + * + * Export folders are `published/{date}/`, so an ISO-date sort ascending means a + * later version's file overwrites an earlier one of the same name — the newest + * snapshot wins without anyone re-deriving `lastUpdated` from the changelog and + * having to agree with what export-png.js wrote. + */ +function newestByName(pngUrls) { + const newest = {}; + for (const path of Object.keys(pngUrls).sort()) { + newest[path.split('/').pop()] = pngUrls[path]; + } + return newest; +} + +/** + * Attach `downloads` and `images` to every chart in `charts`. + * + * `data` may be a single filename, an array of them, or absent. The extension + * is the file type — there is no separate `type` field to keep in sync. + */ +export function resolveDownloads( + charts, + { dataUrls, pngUrls = {}, embedMeta = {} }, + { scale = 2 } = {} +) { + const images = newestByName(pngUrls); + + // Same newest-wins rule as the images: later version folders sort last. + const embeds = {}; + for (const path of Object.keys(embedMeta).sort()) { + Object.assign(embeds, embedMeta[path]); + } + + for (const [key, chart] of Object.entries(charts)) { + // Recorded by export-png.js from a real layout engine. Absent until a + // chart has been exported, in which case ChartFrame measures at runtime. + chart.embed = embeds[key]; + chart.downloads = [chart.data ?? []] + .flat() + .filter(Boolean) + .map((file) => ({ + file, + url: dataUrls[`./data/${file}`], + // "travel-times.geojson" -> "geojson" + type: file.split('.').pop() + })) + .filter((d) => d.url); + + // Files are named by their real pixel width, which is the design width in + // `widths` multiplied by the export scale — see scripts/export-png.js. + chart.images = (chart.widths ?? []) + .map((width) => width * scale) + .sort((a, b) => a - b) + .map((width) => ({ width, file: `${key}-${width}.png`, url: images[`${key}-${width}.png`] })) + .filter((i) => i.url); + } + + return charts; +} diff --git a/src/lib/fonts/OpenSans-Bold.ttf b/src/lib/fonts/OpenSans-Bold.ttf new file mode 100644 index 0000000..98c74e0 Binary files /dev/null and b/src/lib/fonts/OpenSans-Bold.ttf differ diff --git a/src/lib/fonts/OpenSans-BoldItalic.ttf b/src/lib/fonts/OpenSans-BoldItalic.ttf new file mode 100644 index 0000000..8558928 Binary files /dev/null and b/src/lib/fonts/OpenSans-BoldItalic.ttf differ diff --git a/src/lib/fonts/OpenSans-Italic.ttf b/src/lib/fonts/OpenSans-Italic.ttf new file mode 100644 index 0000000..29ff693 Binary files /dev/null and b/src/lib/fonts/OpenSans-Italic.ttf differ diff --git a/src/lib/fonts/OpenSans-Regular.ttf b/src/lib/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..67803bb Binary files /dev/null and b/src/lib/fonts/OpenSans-Regular.ttf differ diff --git a/src/lib/fonts/SourceSerifPro-Bold.otf b/src/lib/fonts/SourceSerifPro-Bold.otf new file mode 100644 index 0000000..034d9d4 Binary files /dev/null and b/src/lib/fonts/SourceSerifPro-Bold.otf differ diff --git a/src/lib/fonts/SourceSerifPro-BoldIt.otf b/src/lib/fonts/SourceSerifPro-BoldIt.otf new file mode 100644 index 0000000..8127399 Binary files /dev/null and b/src/lib/fonts/SourceSerifPro-BoldIt.otf differ diff --git a/src/lib/fonts/SourceSerifPro-It.otf b/src/lib/fonts/SourceSerifPro-It.otf new file mode 100644 index 0000000..a0dec7e Binary files /dev/null and b/src/lib/fonts/SourceSerifPro-It.otf differ diff --git a/src/lib/fonts/SourceSerifPro-Regular.otf b/src/lib/fonts/SourceSerifPro-Regular.otf new file mode 100644 index 0000000..dde2fd4 Binary files /dev/null and b/src/lib/fonts/SourceSerifPro-Regular.otf differ diff --git a/src/lib/fonts/Trade Gothic LT Bold.ttf b/src/lib/fonts/Trade Gothic LT Bold.ttf new file mode 100644 index 0000000..0cd70e0 Binary files /dev/null and b/src/lib/fonts/Trade Gothic LT Bold.ttf differ diff --git a/src/lib/images/sofc-dot-logo.svg b/src/lib/images/sofc-dot-logo.svg new file mode 100644 index 0000000..5a75681 --- /dev/null +++ b/src/lib/images/sofc-dot-logo.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/images/sofc-uoft-logo-black.svg b/src/lib/images/sofc-uoft-logo-black.svg new file mode 100644 index 0000000..0ddc668 --- /dev/null +++ b/src/lib/images/sofc-uoft-logo-black.svg @@ -0,0 +1,428 @@ + + + + diff --git a/src/lib/images/sofc-uoft-logo-blue-colour.svg b/src/lib/images/sofc-uoft-logo-blue-colour.svg new file mode 100644 index 0000000..89295ac --- /dev/null +++ b/src/lib/images/sofc-uoft-logo-blue-colour.svg @@ -0,0 +1,545 @@ + + + + diff --git a/src/lib/images/sofc-uoft-logo-white-colour.svg b/src/lib/images/sofc-uoft-logo-white-colour.svg new file mode 100644 index 0000000..b48321a --- /dev/null +++ b/src/lib/images/sofc-uoft-logo-white-colour.svg @@ -0,0 +1,519 @@ + + + + diff --git a/src/lib/images/sofc-uoft-logo-white.svg b/src/lib/images/sofc-uoft-logo-white.svg new file mode 100644 index 0000000..1f615ba --- /dev/null +++ b/src/lib/images/sofc-uoft-logo-white.svg @@ -0,0 +1,546 @@ + + + + diff --git a/src/lib/meta.js b/src/lib/meta.js new file mode 100644 index 0000000..4eccdea --- /dev/null +++ b/src/lib/meta.js @@ -0,0 +1,141 @@ +// Small helpers shared by anything that reads a post's meta.json — Title.svelte +// for what readers see, PostMeta.svelte for what crawlers read, ChartFrame for +// the licence line under each chart. +// +// These lived in two or three copies before; they're here so a licence added to +// the map below shows up everywhere at once. + +// Known CC license codes -> their deed URL. Unrecognized codes (or none) print +// as plain text rather than becoming a broken or guessed link. +const LICENSE_URLS = { + 'CC-BY-4.0': 'https://creativecommons.org/licenses/by/4.0/', + 'CC-BY-SA-4.0': 'https://creativecommons.org/licenses/by-sa/4.0/', + 'CC-BY-NC-4.0': 'https://creativecommons.org/licenses/by-nc/4.0/', + 'CC-BY-NC-SA-4.0': 'https://creativecommons.org/licenses/by-nc-sa/4.0/', + 'CC-BY-ND-4.0': 'https://creativecommons.org/licenses/by-nd/4.0/', + 'CC-BY-NC-ND-4.0': 'https://creativecommons.org/licenses/by-nc-nd/4.0/', + 'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/' +}; + +export function licenseUrl(license) { + return LICENSE_URLS[license]; +} + +/** "CC-BY-4.0" -> "CC BY 4.0" */ +export function licenseLabel(license) { + return license?.replaceAll('-', ' '); +} + +/** + * Newest changelog date, or undefined when a post has never been revised. + * Derived rather than stored, so adding a changelog entry is the only thing + * needed to mark a post as updated. + */ +export function lastUpdated(changelog) { + if (!changelog || changelog.length === 0) return undefined; + return changelog.reduce((max, entry) => (entry.date > max ? entry.date : max), changelog[0].date); +} + +/** Plain-English join — "A and B", or "A, B, and C" for three or more. */ +export function formatAuthorList(authors = []) { + if (authors.length <= 1) return authors[0] ?? ''; + if (authors.length === 2) return authors.join(' and '); + return `${authors.slice(0, -1).join(', ')}, and ${authors[authors.length - 1]}`; +} + +/** + * The three ways a document in meta.json can relate to a post: + * + * from the post communicates a result from that work — an academic + * paper, an analysis repository, a report we are summarising. + * for the graphics were made because that work needed them. This is + * about why the work was commissioned, not where it ended up: a + * chart reused in a brief six months later is a `resource`, since + * we can't reliably know every place a graphic gets used. + * resource anything else directly related on the same topic — a video, a + * companion piece, a later publication that reused a chart. + * + * A document can be more than one. The common case is a SofC report we made + * the graphics for and whose findings the post then summarises: `from` and + * `for` together. + */ +export const RELATIONS = ['from', 'for', 'resource']; + +// Heading for each combination that can occur, keyed by the relations present +// in `from, for, resource` order. Each document appears once, under the heading +// matching its full set — listing the same report twice would read as two works. +// +// Array rather than an object because the order here is the display order, and +// it should read from what the post is based on, through what it was made for, +// to everything else. Object key order would put `resource` third and every +// combination after it. +const RELATION_HEADINGS = [ + ['from', 'Based on:'], + ['from,for', 'Based on, with graphics created for:'], + ['from,for,resource', 'Based on, with graphics created for:'], + ['for', 'Graphics created for:'], + ['for,resource', 'Graphics created for, and related to:'], + ['from,resource', 'Based on, and related to:'], + ['resource', 'Related:'] +]; + +/** `relation` may be a single value or several; always read it as a list. */ +export function relationsOf(doc) { + return [doc.relation ?? []].flat().filter((r) => RELATIONS.includes(r)); +} + +/** + * Documents grouped by their full set of relations, in a stable order, so the + * byline reads from upstream to downstream regardless of authoring order. + */ +export function documentGroups(documents = []) { + const groups = new Map(); + + for (const doc of documents) { + const key = RELATIONS.filter((r) => relationsOf(doc).includes(r)).join(','); + if (!key) continue; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(doc); + } + + const order = RELATION_HEADINGS.map(([key]) => key); + return [...groups] + .sort((a, b) => order.indexOf(a[0]) - order.indexOf(b[0])) + .map(([key, docs]) => ({ + heading: RELATION_HEADINGS.find(([k]) => k === key)?.[1], + docs + })); +} + +/** + * The image a post represents itself with in a social card — the largest + * exported PNG of one of its charts. + * + * `cardImage` in meta.json names which chart to use. Without it the first chart + * that has an export wins, which makes the card depend on the order charts + * happen to be declared in — fine as a default, worth setting deliberately on a + * post whose best chart isn't its first. + * + * A named chart that hasn't been exported falls through to that same default + * rather than returning nothing, so setting `cardImage` before running + * `npm run export-pngs` doesn't leave the post worse off than not setting it. + * Undefined overall until at least one chart has been exported: a card with no + * image beats a card pointing at a file that doesn't exist. + */ +export function socialImage(charts = {}, cardImage) { + const pick = (chart) => { + const largest = chart?.images?.at(-1); + return largest ? { url: largest.url, alt: chart.alt } : undefined; + }; + + if (cardImage) { + const named = pick(charts[cardImage]); + if (named) return named; + } + + for (const chart of Object.values(charts)) { + const first = pick(chart); + if (first) return first; + } + return undefined; +} diff --git a/src/lib/posts.js b/src/lib/posts.js new file mode 100644 index 0000000..d5e2e28 --- /dev/null +++ b/src/lib/posts.js @@ -0,0 +1,15 @@ +const metas = import.meta.glob('/posts/*/meta.json', { eager: true }); +const charts = import.meta.glob('/posts/*/charts.js', { eager: true }); + +const slugOf = (p) => p.split('/')[2]; + +export const posts = Object.fromEntries( + Object.entries(metas).map(([path, mod]) => [ + slugOf(path), + { meta: mod.default, charts: charts[`/posts/${slugOf(path)}/charts.js`].default } + ]) +); + +export const chartEntries = Object.entries(posts).flatMap(([slug, p]) => + Object.keys(p.charts).map((chart) => ({ slug, chart })) +); diff --git a/src/lib/site.js b/src/lib/site.js new file mode 100644 index 0000000..1a0e015 --- /dev/null +++ b/src/lib/site.js @@ -0,0 +1,23 @@ +// Where the site lives. Both values move together when the domain changes, so +// they're defined here once and imported by svelte.config.js as well as by +// components — a mismatch between them produces links that are subtly wrong +// rather than broken, which is the kind of thing nobody notices for weeks. +// +// Currently the organisation's GitHub Pages site, served from the root of the +// repo named schoolofcities.github.io: +// +// SITE_ORIGIN + BASE_PATH = https://schoolofcities.github.io +// +// BASE_PATH is empty because an org site serves from the domain root. Note that +// sibling project repos still own their own first path segment — /tariffs/ and +// /renters/ are served by those repos, not by this site — so top-level route +// names must not collide with a repo name. +// +// When the site moves to its own domain, only SITE_ORIGIN changes; paths map +// across one to one. +// +// SITE_ORIGIN is scheme + host with no trailing slash; BASE_PATH is a leading +// slash and no trailing one, or '' when served from a domain root. + +export const SITE_ORIGIN = 'https://schoolofcities.github.io'; +export const BASE_PATH = ''; diff --git a/src/lib/styles.css b/src/lib/styles.css new file mode 100644 index 0000000..89100bc --- /dev/null +++ b/src/lib/styles.css @@ -0,0 +1,269 @@ +/* Trade Gothic for headings and titles */ + +@font-face { + font-family: TradeGothicBold; + src: url("fonts/Trade Gothic LT Bold.ttf"); +} + + +/* Source Serif for body text */ + +@font-face { + font-family: SourceSerif; + src: url("fonts/SourceSerifPro-Regular.otf"); +} + +@font-face { + font-family: SourceSerifItalic; + src: url("fonts/SourceSerifPro-It.otf"); +} + +@font-face { + font-family: SourceSerifBold; + src: url("fonts/SourceSerifPro-Bold.otf"); +} + +@font-face { + font-family: SourceSerifBoldItalic; + src: url("fonts/SourceSerifPro-BoldIt.otf"); +} + + +/* Open Sans for smaller text, labels, and UI elements */ + +@font-face { + font-family: OpenSans; + src: url("fonts/OpenSans-Regular.ttf"); +} + +@font-face { + font-family: OpenSansItalic; + src: url("fonts/OpenSans-Italic.ttf"); +} + +@font-face { + font-family: OpenSansBold; + src: url("fonts/OpenSans-Bold.ttf"); +} + +@font-face { + font-family: OpenSansBoldItalic; + src: url("fonts/OpenSans-BoldItalic.ttf"); +} + + +/* brand colours */ + +:root { + font-family: OpenSans, sans-serif; + --brandDarkBlue: #1E3765; + --brandMedBlue: #007FA3; + --brandLightBlue: #6FC7EA; + --brandPurple: #6D247A; + --brandPink: #AB1368; + --brandDarkGreen: #0D534D; + --brandMedGreen: #00A189; + --brandLightGreen: #8DBF2E; + --brandRed: #DC4633; + --brandYellow: #F1C500; + --brandOrange: #EBA00F; + --brandBlack: #000000; + --brandWhite: #ffffff; + --brandGray05: #F2F2F2; + --brandGray10: #E6E6E6; + --brandGray15: #D9D9D9; + --brandGray20: #CCCCCC; + --brandGray25: #BFBFBF; + --brandGray30: #B3B3B3; + --brandGray35: #A6A6A6; + --brandGray40: #999999; + --brandGray45: #8C8C8C; + --brandGray50: #808080; + --brandGray55: #737373; + --brandGray60: #666666; + --brandGray65: #595959; + --brandGray70: #4D4D4D; + --brandGray75: #404040; + --brandGray80: #333333; + --brandGray85: #262626; + --brandGray90: #1A1A1A; + --brandGray95: #0D0D0D; +} + + +/* base page and text styling */ + +body { + padding: 0px; + margin: 0px; + background-color: #fefeff; + min-width: calc(100% - 100dvw + 360px); + width: 100%; +} + +main { + margin: 0 auto; + padding: 0px; + width: 100%; + min-width: calc(100% - 100dvw + 360px); + max-width: 100%; + position: relative; +} + +.text { + background-color: none; + max-width: 680px; + margin: 0 auto; + position: relative; + padding-left: 20px; + padding-right: 20px; + padding-top: 0px; +} + +h1 { + font-family: TradeGothicBold; + font-weight: normal; + font-size: 42px; + color: var(--brandGray90); + text-decoration: none; + margin-bottom: 0px; + padding: 0px; +} + +h2 { + text-align: left; + font-family: TradeGothicBold; + font-weight: normal; + font-size: 30px; + margin-top: 30px; + margin-bottom: 0px; + color: var(--brandBlack); +} + +h3 { + text-align: left; + font-family: TradeGothicBold; + font-weight: normal; + margin-top: 30px; + margin-bottom: 0px; + font-size: 25px; + color: var(--brandBlack); +} + +h4 { + text-align: left; + font-family: TradeGothicBold; + font-weight: normal; + font-size: 20px; + color: var(--brandBlack); +} + +p { + font-family: SourceSerif; + font-weight: normal; + color: var(--brandGray90); + text-align: left; + padding-top: 2px; + padding-bottom: 2px; + font-size: 18px; + line-height: 30px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +@media screen and (max-width: 600px) { + .text p { + font-size: 17px; + line-height: 26px; + } +} + +b { + font-family: SourceSerifBold; + font-weight: normal; +} + +strong { + font-family: SourceSerifBold; + font-weight: normal; +} + +i { + font-family: SourceSerifItalic; + font-weight: normal; +} + +em { + font-family: SourceSerifItalic; + font-weight: normal; +} + +a { + color: var(--brandBlack); + text-decoration: underline; + font-family: SourceSerif; + font-weight: normal; +} + +a:hover { + color: var(--brandMedBlue); +} + +ul { + margin-top: -10px; + margin-bottom: -10px; + margin-left: 0px; + list-style-type: circle; +} + +li { + font-family: SourceSerif; + font-weight: normal; + color: var(--brandGray90); + text-align: left; + padding-top: 2px; + padding-bottom: 2px; + font-size: 17px; + line-height: 22px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +.details-block { + box-sizing: border-box; + max-width: 720px; + margin: auto; + padding: 20px; + border: solid 1px var(--brandGray10); + border-radius: 0px; + background-color: var(--brandWhite); +} + +.details-block summary { + font-family: TradeGothicBold; + font-weight: normal; + font-size: 17px; + color: var(--brandDarkBlue); + cursor: pointer; + padding-bottom: 8px; + border-bottom: solid 1px var(--brandYellow); +} + +.details-block summary:hover { + opacity: 0.85; +} + +.details-block[open] summary { + margin-bottom: 12px; +} + +.details-block p { + font-size: 16px; + line-height: 24px; +} + +.details-block p:last-child { + padding-bottom: 0; +} diff --git a/src/lib/tags.js b/src/lib/tags.js new file mode 100644 index 0000000..2499a57 --- /dev/null +++ b/src/lib/tags.js @@ -0,0 +1,33 @@ +// The permitted values for `tags` and `geography` in each post's meta.json. +// scripts/validate.js warns when a post uses anything not listed here. +// +// Why a fixed list: both fields are read by machines as well as people — +// `tags` becomes `keywords` in the JSON-LD block and `geography` becomes +// `spatialCoverage`. A post tagged "housing" and another tagged "Housing" or +// "housing-policy" read the same to a person and describe two different topics +// to a search engine. Across a hundred posts that can't be corrected after the +// fact, because nobody remembers which spelling was meant where. +// +// Extend either list by hand as new posts need terms. Prefer reusing an +// existing term over adding a near-synonym. + +/** Topic tags. Lowercase. */ +export const TAGS = ['urban', 'housing', 'tax', 'policy', 'public transit', 'accessibility', 'demographics', 'public libraries']; + +/** + * Place names, written as they should appear to a reader — these are published + * as-is in spatialCoverage. List a containing region only when the post + * genuinely covers it. + */ +export const GEOGRAPHY = [ + 'Canada', + 'Alberta', + 'British Columbia', + 'Ontario', + 'Quebec', + 'Calgary', + 'Edmonton', + 'Montreal', + 'Toronto', + 'Vancouver' +]; diff --git a/src/routes/(site)/+layout.svelte b/src/routes/(site)/+layout.svelte new file mode 100644 index 0000000..3bf64f7 --- /dev/null +++ b/src/routes/(site)/+layout.svelte @@ -0,0 +1,22 @@ + + + + + + + +
+
+ {@render children()} +
+