diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..958c14b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: bun + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + openchart: + patterns: + - '@opendata-ai/openchart-*' + react: + patterns: + - react + - react-dom + - '@types/react' + - '@types/react-dom' + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ['*'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6f05acc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI and Pages + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: {} + +jobs: + verify: + name: Check and test production demo + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + pages: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Bun + uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1 + with: + bun-version: '1.4.2' + - name: Set up Node.js + uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0 + with: + node-version: '24' + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Lint, typecheck and test + run: | + bun run lint + bun run typecheck + bun run test + - name: Install browser dependencies + run: bunx playwright install --with-deps chromium webkit + - name: Build and test the nested deployment in every browser profile + env: + STUDIO_TEST_BASE_PATH: /openstudio/ + run: bun run test:e2e + - name: Upload browser evidence + if: failure() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: browser-evidence + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + retention-days: 7 + - name: Configure Pages + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Upload the tested static artifact + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: dist + + deploy: + name: Deploy GitHub Pages + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 5 + concurrency: + group: pages + cancel-in-progress: false + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..df6ae33 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.21.0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7d35293 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +## [0.1.0] - 2026-10-14 + +### Added + +- Standalone OpenStudio browser app, extracted from OpenChart and consuming its published public packages. +- CSV/TSV/JSON imports, explicit data preparation, chart/table/map controls, suggestions and diagnostics, responsive preview, local drafts, undo/redo, portable projects and exports. +- GitHub Pages demo deployment after production tests pass at `/openstudio/` in desktop Chromium, throttled phone Chromium, narrow Chromium and WebKit. + +### Fixed + +- Source replacement, editing and grouping changes require explicit filtering or summarization when observations would otherwise be combined silently. +- Composition controls and suggestions retain transformed fields and the source preparation pipeline across recovery. +- Lollipop composition validates its supported axis orientation and preserves separate series. +- Delimited imports reject malformed quotes and correctly distinguish quoted tabs from TSV delimiters. +- Failed draft recovery preserves downloadable originals before autosave resumes; storage failures cannot overwrite the protected draft. +- Series color picker interactions produce one undoable edit instead of filling history with drag events. +- The deployed notice points to licenses included at the static artifact root. + +### Privacy and hosting + +- Imported data stays in the browser. Drafts use local storage, and downloaded project files preserve source data and preparation. The demo has no backend, accounts, cloud storage or collaboration service. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c54b6cb --- /dev/null +++ b/NOTICE @@ -0,0 +1,14 @@ +OpenStudio + +OpenStudio originates from the OpenChart Studio authoring application in +https://github.com/tryopendata/openchart and uses the public OpenChart packages. +Application source is distributed under the Apache License, Version 2.0. +See LICENSE for the full license and copyright notice. + +Inter font +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) +The bundled src/assets/InterVariable.woff2 is distributed under the SIL Open +Font License, Version 1.1. See public/Inter-LICENSE.txt. Vite copies that license +to Inter-LICENSE.txt in the deployed static artifact. + +Other third-party package licenses remain with their respective packages. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1378647 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# OpenStudio + +A browser-based visualization editor and interactive demo of [OpenChart](https://github.com/tryopendata/openchart). Import your data, prepare it, design a chart, table or map, and download the result. The GitHub Pages demo runs the complete editor locally in your browser, with no account or backend. + +**[Open the demo](https://tryopendata.github.io/openstudio/)** · [Authoring guide](docs/authoring.md) · [Changelog](CHANGELOG.md) · [OpenChart library](https://github.com/tryopendata/openchart) + +## What you can do + +- Explore eight sample documents or import CSV, TSV, OpenChart JSON and Studio project backups. +- Edit values and field types, filter rows, calculate explicit grouped summaries, and replace a dataset while retaining its design. +- Compose bars, lines, areas, scatterplots, lollipops, intervals, histograms and tables; edit axes, colors, labels, facets and annotations. +- Configure table columns, inline visuals, search and phone layouts; inspect map joins, palettes and zoom. +- Inspect exact observations, compare values, preview phone/article/wide layouts, and use suggestions and design diagnostics. +- Undo and redo edits, recover a local draft, download project backups and JSON, and export chart/map SVG or PNG and data CSV. + +See the [authoring guide](docs/authoring.md) for the supported controls and the limits of each workflow. Sample datasets are examples, not live data feeds. + +## Your data + +Local CSV, TSV and JSON files are parsed in the browser. OpenStudio does not send imported datasets to a server. One working draft is stored in browser local storage, scoped to the current browser profile and origin. Different paths on the same origin share that storage; private browsing, clearing site data or switching browsers/hosts can remove or separate drafts. + +Use **Download Studio project** to keep the source data, preparation and visualization together. Plain JSON preserves the visualization document; CSV and image exports are not lossless project backups. Browser storage can be smaller than an accepted import, so a successful import does not guarantee the draft can be saved. The editor reports storage failures. If a draft cannot be reopened, OpenStudio preserves its original text before saving new edits. Use **Download recovery draft** to keep that original. If it cannot be preserved, autosave stays disabled so the saved original is not overwritten; download your current Studio project as well. + +Plain CSV/TSV/JSON imports accept up to **5 MB and 20,000 rows**. Studio project backups accept **20 MB**. There are no cloud projects, accounts, collaborative sessions, public share links or publishing APIs. You can publish downloaded artifacts through your own tools. + +## Develop + +Install Bun 1.4.2 and Node.js 24, then run: + +```sh +bun install --frozen-lockfile +bun run dev # http://127.0.0.1:6007/ +``` + +OpenStudio consumes pinned public OpenChart packages. It builds independently; an OpenChart checkout or library build is not required. + +```sh +bun run lint +bun run typecheck +bun run test +bun run build # dist/ +bun run preview # http://127.0.0.1:6008/ +``` + +For the production browser suite: + +```sh +bunx playwright install chromium webkit +STUDIO_TEST_BASE_PATH=/openstudio/ bun run test:e2e +``` + +On Linux CI, install browsers with `bunx playwright install --with-deps chromium webkit`. The suite builds the static artifact and serves it beneath `/openstudio/` in desktop Chromium, throttled phone Chromium, narrow Chromium and phone WebKit. Port 6008 must be free. Browser reports and traces are written to ignored `playwright-report/` and `test-results/` directories. + +## GitHub Pages deployment + +The repository's [CI and Pages workflow](.github/workflows/ci.yml) checks pull requests and pushes to `main`. It deploys the tested `dist/` artifact after all checks pass on `main`. + +1. In repository **Settings → Pages → Build and deployment**, select **GitHub Actions** as the source. +2. Allow the `github-pages` environment to deploy from `main`; retain any desired reviewer protection. +3. Push or merge to `main`, or manually run **CI and Pages** on `main` from the Actions tab. +4. Open the URL shown by the deployment job: [tryopendata.github.io/openstudio/](https://tryopendata.github.io/openstudio/). + +The deploy job uses GitHub's built-in token and OIDC. No application secrets, API credentials or backend deployment are needed. See [GitHub's custom workflow documentation](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) for repository and environment setup. + +Vite keeps `base: './'`: scripts, styles and the bundled font use relative URLs. Preserve the trailing slash for nested deployments. To use another static host, serve the whole `dist/` directory over HTTPS, cache hashed assets immutably and revalidate HTML. Development, preview and QA servers are local tools. To roll back the demo, revert the affected commit on `main` and let the workflow rebuild and deploy it. + +## License + +Application code is licensed under [Apache-2.0](LICENSE). The bundled Inter font is licensed under the [SIL Open Font License 1.1](public/Inter-LICENSE.txt); that license is also included in the deployed site. See [NOTICE](NOTICE) for attribution. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..890d379 --- /dev/null +++ b/biome.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", + "files": { + "includes": [ + "src/**" + ] + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "css": { + "formatter": { + "enabled": true + }, + "linter": { + "enabled": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "linter": { + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error", + "useExhaustiveDependencies": "warn" + }, + "style": { + "noNonNullAssertion": "off", + "useImportType": "error" + }, + "suspicious": { + "noExplicitAny": "on", + "noAssignInExpressions": "off" + } + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..26a74f6 --- /dev/null +++ b/bun.lock @@ -0,0 +1,370 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "@opendata-ai/studio", + "dependencies": { + "@opendata-ai/openchart-core": "8.9.0", + "@opendata-ai/openchart-engine": "8.9.0", + "@opendata-ai/openchart-vanilla": "8.9.0", + "d3-dsv": "3.0.1", + "gifenc": "1.0.3", + "motion": "14.1.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "world-atlas": "2.0.2", + }, + "devDependencies": { + "@biomejs/biome": "2.4.4", + "@playwright/test": "1.59.1", + "@types/d3-dsv": "3.0.7", + "@types/node": "24.19.2", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "7.0.2", + "vite": "7.3.1", + "vitest": "5.0.4", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@opendata-ai/openchart-core": ["@opendata-ai/openchart-core@8.9.0", "", { "dependencies": { "d3-color": "^3.1.0", "d3-format": "^3.1.0", "d3-time-format": "^4.1.0" } }, "sha512-o6JXInZdq5++JF834ZqB1Bo/Y8uRc8EzOVP58mPvoKQSOEml69vmew0Cwumw163qeD3PTY84sSnQRVeWQD9DJA=="], + + "@opendata-ai/openchart-engine": ["@opendata-ai/openchart-engine@8.9.0", "", { "dependencies": { "@opendata-ai/openchart-core": "8.9.0", "d3-array": "^3.2.0", "d3-format": "^3.1.2", "d3-geo": "^3.1.0", "d3-interpolate": "^3.0.0", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.0", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "topojson-client": "^3.1.0" } }, "sha512-5IIpd2pA6NSWLdIIISM4yDx5uKvhhL/X2NDCebTPy3jNpMDHpvvUr0g5KltwCJHX1mUZdI/CdoKo9NJ8lghOKg=="], + + "@opendata-ai/openchart-vanilla": ["@opendata-ai/openchart-vanilla@8.9.0", "", { "dependencies": { "@floating-ui/dom": "^1.7.6", "@opendata-ai/openchart-core": "8.9.0", "@opendata-ai/openchart-engine": "8.9.0", "d3-force": "^3.0.0", "d3-force-3d": "^3.0.6", "d3-interpolate": "^3.0.0", "d3-quadtree": "^3.0.1" }, "peerDependencies": { "3d-force-graph": ">=1.80.0", "gifenc": ">=1.0.0", "happy-dom": ">=14.0.0", "three": ">=0.179 <1", "three-spritetext": ">=1.9.0" }, "optionalPeers": ["3d-force-graph", "gifenc", "happy-dom", "three", "three-spritetext"] }, "sha512-smLFSlaSxBUe0Jnhc4PELZNEXQptKHDKeLEeaZUpcQdUhUjLIDj6YOk7N2SoCQOtg+Cko8IE4PEUWPIcX7Icmg=="], + + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.6", "", { "os": "android", "cpu": "arm" }, "sha512-FkO19Fowv/OQeJZPVfdetJaI3/VpbZmBsnmAlR/rQ+b3QwfiGQiOpVegpSRH331kelvHTVw9hXYG3DwjAaRzyQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.6", "", { "os": "android", "cpu": "arm64" }, "sha512-si5/Etm6MphPc2xYHnk455Shls/hoMjTy3mhXge4OPmvCw7TwC4KP4fKokAlKLV4XaQvLvgSHfafPcblLMYYdQ=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PQCl3q7b0BgvVt72i2xrWM6I5KliwwVe4GpWW5QDGyUQawDS9aPZss/aGyFqBmxdRxlE8wDc9CaxiQ5DHRSp/Q=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-l9DFl1Itb1YYdSrP1K3U8ih44AOHa/p+Q4WMMDfGvH5qVWp+ajfgS1DlYo4yCRh8wJ2ZJv0Fqccf+ULAmMFjmg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.6", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-bR4GR3205N9+od0+l5Dd9/iZTDpJkIe6d6Ydp2qbcQZJLmKBdYQQRp8gbWpbMt92twKJY66o1vK4NkoKQODIrA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XTVNaDzcFl2TeBglvxdGgsCv9JGtAGRAoJENmGBR/XvDH2mA2K0IXc2aMfP0dR8mexSXSKifJ85FixoXX1wfTw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.6", "", { "os": "linux", "cpu": "arm" }, "sha512-qWc6qbc+25ugG9vqPR9P422KJBbVfZ05mjcMggoq8tsfWP+eRfC+/tiDdIlRF5VL2pDVZWN9zAhWsmu7R1+7Dw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.6", "", { "os": "linux", "cpu": "arm" }, "sha512-V4Nwkp8844X/Xnww3PI1LYS0YPei8XQEVG70R+5MPZGO7AhYoun4hCN7Svs4U3wbQLZbwiz+9xRFhUZZdRl8Ww=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-xb72A1aPaaIsp+a88VtRae/ovIEXislbUMyp7b9AQQU7ZkP2m/+ik6DW9TQcJyw5R8/RIailF9zExYwkjR++UA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-pbcs1BVBwC43hZkjhjlF8Rx8m5G0KnhLZs9Azy/gqccP6UsNSzJr0hKGIAVPqzAH1O/NdPxxC9GVGb4msQg/aw=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.6", "", { "os": "linux", "cpu": "none" }, "sha512-EKOC8os6Vsg487ZLKUqzLF99Jsmnq6tu7O7mAELakNpcA+8KadktFf/JYdRuHe/DzCUXmN38pUKu4XBcKlqGbQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.6", "", { "os": "linux", "cpu": "none" }, "sha512-v/SQ68GnSlr/HyjB79sNXesGDwkOPu9JjbHfmWaymCiM+nZsQ9TstG1KfA4MuEvTr2d3PoY8PhFxj9bt0e32JA=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-G3pmliMQ7r15zFFNwBlaT/xTdSLi4lfgI7CUrQueyKYheNVhrd8JrWCE6Ll8xf4AA1RZFqq+mNJYntiElsVOyA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XQTB/IPoB/wG6x/wu9uomAt8YH61IAr5iZTubMiad/lu88sotq2ocz9hJd556+Jz2kIkF7ZQZIsUuWE3OWqtcA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.6", "", { "os": "linux", "cpu": "none" }, "sha512-O5mpokth6+ydMuwiGtY8fIjvOBfH7u6SzmDNmZ3k6GK5C6iEAC2q2ZN/T5L9jYILx0IcCT0KnfMDihQ5yQFSBw=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.6", "", { "os": "linux", "cpu": "none" }, "sha512-aIFxbAD9v3TvIaEDsKmukjcDg1JS4/tFXw8SHyQQgzOwzWCQ4cyDK7Ju1pQKqUXDFkEu/BZCFDCDATsKhQcB/Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-fuNo1UwBc6BxnHDOxxguQL/InhheMN3RJcJMb0b4xP8r5Q3CNCrJbVg0eCIFuJ4e/zAfjR2gYbOfbzO/G7CIyw=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.6", "", { "os": "linux", "cpu": "x64" }, "sha512-05v6wFKDKJPZs9xlytVrrRu6IbRuoSvlH9xNwEOafXsKyjBFZPvAcZz2VIAB/M+lblYeKa9iOYC4RDVdfcAAaw=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.6", "", { "os": "linux", "cpu": "x64" }, "sha512-wJXKcdMjvkc/FaavZXNP1yW4i0pfOmhZAIWRSLG2yJ0PB0TWi7dkLErhX0u0vptDcXKKO/m1Y/YTeuyvb2MXwg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.6", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cwg7/h0ThvJwy8CBkmACdiHAxRWpbsCmIsjOt+Zzx0hTnIcSUmKYEV03bGg+hD827OnwUsbKh1ZZDW754xWyBA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.6", "", { "os": "none", "cpu": "arm64" }, "sha512-T0RkYIJbdYXJEQFw+tMBO09KDFdgmNxkHsof3wWAABrvnx15Yh8RytmrgYy9+q9cBeBNZF66MMQZC+5nzEz6vg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-KlfpmF1AAIdAWxt8QQNClbpLFlAUfYOzqIuJ7IXEStMHpFRuH78RG6FjqPb3Rqhh0K89F5npvG+/i3Fjl6IveQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-MrTLjd7Jlt9/ZavJZYG61KQv9Iy9bzbY+yXy73Xvwoa2uP66wvDShkyBr5Chz/yrE6lbD/zHMuTmKQVNQqBApg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.6", "", { "os": "win32", "cpu": "x64" }, "sha512-8fWDH1CkPI8GdBKAxzHDza7UmgficsKo2+MVbvUgOGhB0629w5sHMg3Qu5y7TZsrYKHHWpz8fla4KvoT7WsO3Q=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.6", "", { "os": "win32", "cpu": "x64" }, "sha512-9/c9PWdr5GCIHkkxiHM1HjNBQMVdftP4O8QTcoUAaRza2FSqJ4axbf8QAnBuE6BIGQDl71SaRPeDdDYid5ecTw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@24.19.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@vitest/mocker": ["@vitest/mocker@5.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.4", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, "peerDependencies": { "msw": "^2.4.9 || ^3.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ=="], + + "@vitest/spy": ["@vitest/spy@5.0.4", "", {}, "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], + + "commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-binarytree": ["d3-binarytree@1.0.2", "", {}, "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "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" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-force-3d": ["d3-force-3d@3.0.6", "", { "dependencies": { "d3-binarytree": "1", "d3-dispatch": "1 - 3", "d3-octree": "1", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-octree": ["d3-octree@1.1.0", "", {}, "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "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" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "framer-motion": ["framer-motion@14.1.0", "", { "dependencies": { "motion-dom": "14.1.0", "motion-utils": "14.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-QsdZSdWoyQfUmgi+GZfow8Du5COG6VRYEo9FlgKUPgH87/Le/fYekHCAKwLdYKHdCZcZfWHg4iOxlD6RoqXciQ=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gifenc": ["gifenc@1.0.3", "", {}, "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "magic-string": ["magic-string@1.4.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ=="], + + "motion": ["motion@14.1.0", "", { "dependencies": { "framer-motion": "14.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-lsCsW9uJ+iMJj8MSiLccpUC+ZUpoSspaX6Pm78KrnZKMioCPAPG6WeG1d1d/Ywg+z1mny+STP6VyEYW+3nAqNw=="], + + "motion-dom": ["motion-dom@14.1.0", "", { "dependencies": { "motion-utils": "14.1.0" } }, "sha512-FyFLkNUAAETzCvf851mZcl+v+kaZrKq+UM+XmkY4G8n+ny/WYfiWh9j0fhSQ1KOKVQrM9/cLO11THO2k71HJlg=="], + + "motion-utils": ["motion-utils@14.1.0", "", {}, "sha512-xd4FeWOnnXsJHmAaRCLNbOS7mEaNOangjMt3Pl241H07f1kxW48fADQ/8O5hLLNhf1qdlozgY3PPAHBdOoU7AA=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + + "postcss": ["postcss@8.5.30", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "rollup": ["rollup@4.64.6", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.6", "@rollup/rollup-android-arm64": "4.64.6", "@rollup/rollup-darwin-arm64": "4.64.6", "@rollup/rollup-darwin-x64": "4.64.6", "@rollup/rollup-freebsd-arm64": "4.64.6", "@rollup/rollup-freebsd-x64": "4.64.6", "@rollup/rollup-linux-arm-gnueabihf": "4.64.6", "@rollup/rollup-linux-arm-musleabihf": "4.64.6", "@rollup/rollup-linux-arm64-gnu": "4.64.6", "@rollup/rollup-linux-arm64-musl": "4.64.6", "@rollup/rollup-linux-loong64-gnu": "4.64.6", "@rollup/rollup-linux-loong64-musl": "4.64.6", "@rollup/rollup-linux-ppc64-gnu": "4.64.6", "@rollup/rollup-linux-ppc64-musl": "4.64.6", "@rollup/rollup-linux-riscv64-gnu": "4.64.6", "@rollup/rollup-linux-riscv64-musl": "4.64.6", "@rollup/rollup-linux-s390x-gnu": "4.64.6", "@rollup/rollup-linux-x64-gnu": "4.64.6", "@rollup/rollup-linux-x64-musl": "4.64.6", "@rollup/rollup-openbsd-x64": "4.64.6", "@rollup/rollup-openharmony-arm64": "4.64.6", "@rollup/rollup-win32-arm64-msvc": "4.64.6", "@rollup/rollup-win32-ia32-msvc": "4.64.6", "@rollup/rollup-win32-x64-gnu": "4.64.6", "@rollup/rollup-win32-x64-msvc": "4.64.6", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-fX1Vy+UNKdXn+xKDnu/HBXbHVr1AY8cSdQj9BU4kLv5aFCMqGpvrVbyUMmjN3DdzWhSAuTbcX8PCNEVaumthYA=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + + "tinybench": ["tinybench@6.2.2", "", {}, "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vitest": ["vitest@5.0.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.4", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.4.3", "obug": "^3.0.0", "picomatch": "^4.0.7", "std-env": "^4.3.0", "tinybench": "^6.2.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.4", "@vitest/browser-preview": "5.0.4", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.4", "@vitest/coverage-v8": "5.0.4", "@vitest/ui": "5.0.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg=="], + + "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": { "why-is-node-running": "cli.js" } }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], + + "world-atlas": ["world-atlas@2.0.2", "", {}, "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + } +} diff --git a/docs/authoring.md b/docs/authoring.md new file mode 100644 index 0000000..e6f779e --- /dev/null +++ b/docs/authoring.md @@ -0,0 +1,81 @@ +# Authoring in OpenStudio + +OpenStudio is a client-side editor built on the public [OpenChart](https://github.com/tryopendata/openchart) packages. The [GitHub Pages demo](https://tryopendata.github.io/openstudio/) runs the same application as a local build. All authoring, preparation, preview, validation and export work happens in the browser. + +## Start with data + +Choose one of eight sample documents, paste CSV/TSV, open a local file, or import a supported chart, table or map JSON document. A sample replaces the current document and can be undone. Example datasets are not live data feeds; read each chart's source and methodology. + +Plain document and delimited imports accept at most 5 MB, including normalized row expansion, and 20,000 rows. Studio project backups accept 20 MB to accommodate the source plus prepared document. Headers must be unique and nonempty, and row lengths must match. Quoted delimiters, escaped quotes and newlines work; malformed quoting is rejected before parsing. Leading-zero identifiers remain text. Dates require valid ISO calendar dates or timezone-qualified timestamps. + +CSV/TSV rows receive unique persistent keys. Explicit type corrections are saved in field metadata; internal identity fields remain out of ordinary data controls. Blank cells remain missing observations. Edit source values or field types in **Data → Source data**. Preparation and the visualization are recomputed from those source rows. + +## Prepare and replace + +Preparation filters exact values first, then applies an explicitly selected grouped sum, mean, minimum, maximum or row count. Numeric summaries exclude missing and nonnumeric observations; groups with no measurements remain missing. Count includes every row and receives separate row-count metadata, without inherited currency or percentage formatting. + +The source stays intact. **Prepared data** shows the rows passed to the visualization document. **Restore all source rows** removes the Studio preparation recipe. Each applied preparation is one undoable edit. + +**Import data → Replace data · keep this design** maps every existing field to a different replacement column. It retains the headline, appearance and preparation. Replacement observations receive new imported identities when an internal identity field is used. Undo restores the preceding source and design together. + +Repeated category/series observations require an explicit filter or summary before composition. Source replacement, source edits, regrouping and changes to the plotted grouping cannot silently collapse observations into an implicit total. Change to a table when you want to expose every source row before choosing a new summary. An explicitly authored chart imported through JSON can retain its existing aggregation semantics. + +## Compose and design + +| Workflow | Available controls | +| --- | --- | +| Compose | Bars, lines, areas, scatterplots, lollipops, intervals/before-and-after, histograms and tables, with explicit field roles | +| Axes | Fields, titles, visibility, numeric formats, zero baselines and category order | +| Series | Field mapping, individual colors, label density, facets and area stacking | +| Annotations | Anchored text callouts and reference lines; direct text editing and label/annotation dragging | +| Motion | Fluid, subtle, legacy and off | +| Tables | Column order, headings, formats, priorities, inline bars, heatmaps, change chips, search, pagination and phone layouts | +| Maps | Data and feature join fields, color measure/format/palette, zoom, and duplicate/missing/unmatched key feedback | + +**Change composition** retains data, headline and source while resetting composition-specific axes, labels and annotations. Undo restores them. Lines use dates or ordered categories along X. Lollipops require a numeric X field and a category Y field. Interval authoring uses numeric X/start and endpoint fields with a category Y field; supplied bounds are used as supplied and are not calculated confidence intervals. + +Chart-to-chart composition and **Suggest** retain authored transform pipelines. Field selectors use those pipelines' output fields, so a calculated or aggregated measure remains available. Summarizing an authored pipeline requires changing to its source table first. Converting to a table exposes source rows; it does not invent another aggregation. For a chart with authored transforms, the prepared-row preview and CSV reflect the rows supplied to the transform pipeline, not necessarily its final plotted marks. + +Map joins leave ambiguous observations uncolored and explain the join problem. A duplicate never silently wins. Missing observations remain distinct from measured zeroes. Maps start from a supplied geography or imported map document; the editor does not fetch or infer a new geography from place names. + +## Preview and inspect + +Canvas width is a target capped by available space. Phone, article and wide previews help check the same document at different widths. Mobile authoring uses sheets and preserves unfinished composition/annotation choices when dismissed. Replacing the source clears obsolete pending field choices. + +Chart reader mode supports hover previews, pinning up to two observations, arrow-key navigation, Enter to pin, and Escape to clear. Map and table activation pins one observation, cleared when the document changes. Aggregate groups are identified explicitly. A selection without persistent identity does not silently survive a data revision. + +Direct chart editing and reader inspection are separate modes. Entering **Edit chart elements** returns a temporal chart to all years before an edit can be committed. Timeline scrubbing filters only the preview; complete data stays editable and saved. SVG/PNG follow the displayed time window; CSV and JSON retain the complete document data. Editing in the Data view retains the composition used for image export. + +**Suggest** uses chosen field roles and deterministic rules. **Review** offers measurable diagnostics and optional, undoable patches. A patch is guarded against a later document edit. Geometry review covers Cartesian charts; map/table checks and a complete editorial or accessibility audit still require author judgment. + +## Save, recover and export + +| Output | Preserves | +| --- | --- | +| Download Studio project | Source rows, source metadata/identity, preparation recipe and ordinary OpenChart `VizSpec` | +| JSON document | The visualization specification and its data; not the separate Studio source/preparation recipe | +| Source CSV | Complete source rows | +| Plotted CSV / CSV export | Complete data supplied to the visualization, including Studio preparation; independent of timeline preview and table pagination | +| SVG / PNG | Displayed chart or map, using terminal geometry even during an update; available from Canvas or Data view | + +Tables export CSV, not images. All supported documents can be downloaded as JSON. The JSON drawer is optional; it reads and writes the same `VizSpec` used by OpenChart library consumers. + +History stores up to 100 complete project edits. Native text-field undo applies while typing; document undo applies outside text controls. Series colors commit when you leave the picker, so dragging produces one undoable edit. Undo history, temporary panel choices and transient inspection are not saved across reloads. + +One working draft is saved after edits and flushed when the page is hidden. Drafts belong to the current browser profile and origin, not an account or repository path. Pages hosted beneath different paths on the same origin can share the same local draft. Storage restrictions, private browsing or clearing site data can prevent recovery. Moving to another browser, device or origin requires downloading and importing a Studio project. + +Storage failures are reported explicitly. A draft that cannot be reopened is preserved as a separate recovery copy before new edits can autosave. Restarting after an editor failure follows the same rule. Use **Recovery draft** and **Download recovery draft** to save the original text; distinct recovery copies do not replace each other. If browser storage cannot preserve an original, autosave stays disabled and the original remains available for download. You can continue editing and download the current Studio project; reopening the editor retries preservation. Use project downloads for durable backups, especially for larger datasets: browser local storage may not hold a project within the import limit. Files are parsed locally; OpenStudio does not upload imported datasets. Hosting serves the application assets, and author-supplied external resource URLs can still be requested by the browser. + +## Application boundary and limitations + +OpenStudio owns its React/Motion UI, data imports, projects, local drafts, examples, fonts and build tooling. OpenChart owns the public specification, compiler and renderer packages. The application imports only public package exports and does not require the OpenChart repository to build. `StudioProject` is an app-owned persistence envelope around a `VizSpec`, not another visualization grammar. + +The demo has no accounts, cloud persistence, collaborative editing, hosted share links, publish/revoke service or embed service. Hosting it on GitHub Pages enables the complete browser editor; those server-side workflows are outside its scope. Download artifacts for use in your own publishing workflow. + +Controls expose a supported subset of OpenChart. Existing settings can be retained through JSON, but the UI does not author every chart family, layer grammar, transform or annotation option. Linked-view orchestration, uncertainty-band authoring and generalized narrative documents are not implemented. Design checks do not establish statistical correctness or replace testing with readers. + +## Build and verification + +See the [README](../README.md) for installation, deployment and rollback instructions. `vite.config.ts` retains a relative base, so the whole `dist/` directory can be served at an origin or a trailing-slash path without a rewrite router, backend or external font service. + +`playwright.config.ts` tests the production artifact in desktop Chromium, 390px Chromium with 4× CPU throttling, 320px Chromium, and 390px WebKit. CI runs the matrix at `/openstudio/`, covering patterns, fonts, imports/type correction, preparation, edits, undo/redo, draft recovery, field selections, keyboard values, selection invalidation, exports, reduced motion and resize. Unit/integration tests exercise document and preparation behavior against the actual OpenChart packages. Failure screenshots, traces and videos are attached to CI runs. diff --git a/e2e/authoring.spec.ts b/e2e/authoring.spec.ts new file mode 100644 index 0000000..2330a76 --- /dev/null +++ b/e2e/authoring.spec.ts @@ -0,0 +1,293 @@ +import { readFile } from 'node:fs/promises'; +import { expect, test, type Page } from '@playwright/test'; + +async function inspector(page: Page) { + const edit = page.getByRole('button', { name: 'Edit', exact: true }); + if (await edit.isVisible()) await edit.click(); + const panel = page + .getByRole('dialog', { name: 'Refine your visualization' }) + .or(page.locator('.studio-inspector:visible')); + await panel.getByRole('tab', { name: 'Design', exact: true }).click(); + return panel; +} +async function closeSheet(page: Page) { + const dialog = page.getByRole('dialog'); + if (await dialog.isVisible()) await dialog.getByRole('button', { name: /^Close / }).click(); +} +async function importData(page: Page, data: string) { + await page.getByRole('button', { name: 'Import data' }).click(); + await page.getByLabel('Or paste data').fill(data); + await page.getByLabel('Import action').selectOption('new'); + await page.getByRole('button', { name: 'Preview your data →' }).click(); +} +async function projectBackup(page: Page) { + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download Studio project' }).click(); + return JSON.parse(await readFile((await (await download).path())!, 'utf8')); +} +test.beforeEach(async ({ page }, info) => { + if (info.project.metadata.cpuThrottle) { + const session = await page.context().newCDPSession(page); + await session.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + } + await page.goto('./'); +}); + +test('summaries retain source data, export/reopen, and compose into an annotated chart', async ({ + page, +}, info) => { + await importData( + page, + 'region,year,value\nNorth,2024,10\nNorth,2025,\nSouth,2024,20\nSouth,2025,40', + ); + await page.getByRole('combobox', { name: 'Summary', exact: true }).selectOption('mean'); + await page.getByLabel('Summarize field').selectOption('value'); + await page.getByRole('group', { name: 'Group by' }).getByLabel('region', { exact: true }).check(); + await page.getByRole('button', { name: 'Apply preparation' }).click(); + await page.getByRole('button', { name: 'Prepared data', exact: true }).click(); + await expect(page.locator('.studio-prepared-table tbody tr')).toHaveText(['North10', 'South30']); + await page.getByRole('button', { name: 'JSON document' }).click(); + await page.getByRole('button', { name: 'Apply document' }).click(); + const backup = await projectBackup(page); + expect(backup.source).toHaveLength(4); + expect(backup.preparation.summary.op).toBe('mean'); + await importData(page, JSON.stringify(backup)); + await page.getByRole('button', { name: 'Prepared data', exact: true }).click(); + await expect(page.locator('.studio-prepared-table tbody tr')).toHaveText(['North10', 'South30']); + const panel = await inspector(page); + await panel.getByText('Change composition', { exact: true }).click(); + await panel.getByRole('combobox', { name: 'Composition', exact: true }).selectOption('bar'); + await panel.getByLabel('Horizontal field').selectOption('value'); + await panel.getByLabel('Vertical field').selectOption('region'); + await panel.getByRole('button', { name: 'Apply composition' }).click(); + await panel.getByText('Annotations & reference lines', { exact: true }).click(); + await panel.getByLabel('Reference axis').selectOption('x'); + await panel.getByLabel('X anchor').fill('20'); + await panel.getByLabel('Annotation text').fill('Target'); + await panel.getByRole('button', { name: 'Add annotation' }).click(); + await closeSheet(page); + await page.getByRole('button', { name: 'Canvas', exact: true }).click(); + await expect(page.locator('.studio-paper')).toContainText('Target'); + const svg = page.waitForEvent('download'); + await page.getByLabel('Export format').selectOption('svg'); + await page.getByRole('button', { name: 'Export ↗' }).click(); + expect(await readFile((await (await svg).path())!, 'utf8')).toContain('Target'); + const final = await projectBackup(page); + expect(final.source).toHaveLength(4); + expect(final.spec.data.map((row: { value: number }) => row.value)).toEqual([10, 30]); + await page.screenshot({ path: info.outputPath('authored-comparison.png'), fullPage: true }); +}); + +test('replacement data can remap columns while retaining design and undoing the transaction', async ({ + page, +}) => { + await importData(page, 'region,value\nA,10\nB,30'); + const panel = await inspector(page); + await panel.getByRole('textbox', { name: 'Headline', exact: true }).fill('A reusable comparison'); + await panel.getByRole('textbox', { name: 'Headline', exact: true }).press('Tab'); + await closeSheet(page); + await page.getByRole('button', { name: 'Import data' }).click(); + await page.getByLabel('Or paste data').fill('area,amount\nC,70\nD,90'); + await page.getByLabel('Import action').selectOption('replace'); + const mapping = page.getByRole('group', { name: 'Match replacement columns' }); + await mapping.getByRole('combobox', { name: 'region', exact: true }).selectOption('area'); + await mapping.getByRole('combobox', { name: 'value', exact: true }).selectOption('amount'); + await page.getByRole('button', { name: 'Preview your data →' }).click(); + await expect(page.getByLabel('region, row 1', { exact: true })).toHaveValue('C'); + let backup = await projectBackup(page); + expect(backup.spec.chrome.title).toBe('A reusable comparison'); + expect(backup.spec.type).toBe('table'); + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + await expect(page.getByLabel('region, row 1', { exact: true })).toHaveValue('A'); + backup = await projectBackup(page); + expect(backup.source.map((row: { value: number }) => row.value)).toEqual([10, 30]); +}); + +test('phone inspector preserves unfinished composition and annotation choices', async ({ + page, +}) => { + let panel = await inspector(page); + await panel.getByText('Change composition', { exact: true }).click(); + await panel.getByRole('combobox', { name: 'Composition', exact: true }).selectOption('point'); + await panel.getByLabel('Horizontal field').selectOption('revenue'); + await panel.getByText('Annotations & reference lines', { exact: true }).click(); + await panel.getByLabel('Y anchor').fill('300'); + await panel.getByLabel('Annotation text').fill('Unfinished annotation'); + await closeSheet(page); + panel = await inspector(page); + if (!(await panel.getByRole('combobox', { name: 'Composition', exact: true }).isVisible())) + await panel.getByText('Change composition', { exact: true }).click(); + if (!(await panel.getByLabel('Annotation text').isVisible())) + await panel.getByText('Annotations & reference lines', { exact: true }).click(); + await expect(panel.getByRole('combobox', { name: 'Composition', exact: true })).toHaveValue( + 'point', + ); + await expect(panel.getByLabel('Horizontal field')).toHaveValue('revenue'); + await expect(panel.getByLabel('Y anchor')).toHaveValue('300'); + await expect(panel.getByLabel('Annotation text')).toHaveValue('Unfinished annotation'); + await closeSheet(page); + await page.getByRole('button', { name: 'Data', exact: true }).click(); + await page.getByLabel('Filter field').selectOption('company'); + await importData(page, 'region,value\nNew,10'); + await expect(page.getByLabel('Filter field')).toHaveValue(''); +}); + +test('prepared tables align sparse JSON rows and counts discard percentage formatting', async ({ + page, +}, info) => { + await importData( + page, + JSON.stringify({ + type: 'table', + data: [ + { name: 'A', value: 0.2 }, + { value: 0.3, name: 'A', note: 'estimate' }, + { name: 'B', note: 'missing' }, + ], + fields: { value: { type: 'number', unit: '%', representation: 'proportion' } }, + columns: [{ key: 'name' }, { key: 'value', format: '.1%' }], + pagination: true, + }), + ); + await page.getByRole('button', { name: 'Prepared data', exact: true }).click(); + await expect(page.locator('.studio-prepared-table thead')).toHaveText('namevaluenote'); + await expect(page.locator('.studio-prepared-table tbody tr')).toHaveText([ + 'A0.2—', + 'A0.3estimate', + 'B—missing', + ]); + await page.getByRole('combobox', { name: 'Summary', exact: true }).selectOption('count'); + await page.getByRole('group', { name: 'Group by' }).getByLabel('name', { exact: true }).check(); + await page.getByRole('button', { name: 'Apply preparation' }).click(); + const panel = await inspector(page); + await expect(panel.getByLabel('Rows per page')).toHaveValue('25'); + await expect(panel.getByLabel('Phone layout')).toHaveValue('cards'); + await closeSheet(page); + await page.getByRole('button', { name: 'Canvas', exact: true }).click(); + await expect(page.locator('.studio-paper')).toContainText('Row count'); + await expect(page.locator('.studio-paper')).not.toContainText('%'); + await page.screenshot({ path: info.outputPath('count-table.png'), fullPage: true }); + await page.getByRole('button', { name: 'Data', exact: true }).click(); + await page.getByRole('button', { name: 'Restore all source rows' }).click(); + const backup = await projectBackup(page); + expect(backup.spec.columns).toHaveLength(2); + expect(backup.spec.columns[1].format).toBe('.1%'); +}); + +test('map palette controls change actual fills and keep missing-data hatches valid during updates', async ({ + page, +}, info) => { + const patterns = page.getByRole('button', { name: 'Patterns', exact: true }); + if (await patterns.isVisible()) await patterns.click(); + await page.getByRole('button', { name: /A geographic lens/ }).click(); + const country = page.locator('.oc-map-feature[data-feature-id="840"]'); + const before = await country.getAttribute('fill'); + const panel = await inspector(page); + await panel.getByRole('combobox', { name: 'Map palette', exact: true }).selectOption('teal'); + await panel.getByRole('combobox', { name: 'Number format', exact: true }).selectOption('$,.0f'); + await closeSheet(page); + const invalidPaint = await page.evaluate(async () => { + const failures: string[] = []; + for (let i = 0; i < 30; i++) { + await new Promise(requestAnimationFrame); + for (const mark of document.querySelectorAll('.oc-map-feature[data-nodata]')) { + const fill = mark.getAttribute('fill') ?? ''; + const id = /^url\(#(.+)\)$/.exec(fill)?.[1]; + if (!id || !document.getElementById(id)) failures.push(fill); + } + } + return failures; + }); + expect(invalidPaint).toEqual([]); + await expect(country).not.toHaveAttribute('fill', before!); + const labels = await page + .locator('.studio-paper svg text') + .evaluateAll((nodes) => + nodes + .filter((node) => /^\$[\d,]+$/.test(node.textContent ?? '')) + .map((node) => ({ + text: node.textContent, + left: node.getBoundingClientRect().left, + right: node.getBoundingClientRect().right, + top: node.getBoundingClientRect().top, + })), + ); + expect(labels.length).toBeGreaterThan(0); + for (let i = 1; i < labels.length; i++) + if (Math.abs(labels[i].top - labels[i - 1].top) < 1) + expect(labels[i].left).toBeGreaterThan(labels[i - 1].right); + await page.screenshot({ path: info.outputPath('authored-map.png'), fullPage: true }); +}); + +test('table search keeps keyboard focus after filtering changes the preview height', async ({ + page, +}) => { + const patterns = page.getByRole('button', { name: 'Patterns', exact: true }); + if (await patterns.isVisible()) await patterns.click(); + await page.getByRole('button', { name: /The details matter/ }).click(); + const search = page.getByRole('searchbox', { name: 'Search table' }); + await search.fill('MSFT'); + await expect(page.locator('.studio-paper tbody tr')).toHaveCount(1); + // The table's ResizeObserver follows the debounced search render by 100 ms. + await page.waitForTimeout(250); + await expect(search).toBeFocused(); + await search.press('Backspace'); + await expect(search).toHaveValue('MSF'); +}); + +test('suggestions use authored transform outputs and preserve the source on recovery', async ({ + page, +}) => { + const source = [ + { region: 'North', value: 10 }, + { region: 'North', value: 30 }, + { region: 'South', value: 50 }, + ]; + const transform = [ + { aggregate: [{ field: 'value', op: 'mean', as: 'average' }], groupby: ['region'] }, + ]; + await importData( + page, + JSON.stringify({ + mark: 'bar', + data: source, + transform, + encoding: { + x: { field: 'region', type: 'nominal' }, + y: { field: 'average', type: 'quantitative' }, + }, + }), + ); + const panel = await inspector(page); + await expect(panel.getByRole('combobox', { name: 'Y axis', exact: true })).toHaveValue('average'); + await panel.getByRole('tab', { name: 'Suggest', exact: true }).click(); + await panel.getByRole('combobox', { name: 'Categories', exact: true }).selectOption('region'); + await panel.getByRole('combobox', { name: 'Measure', exact: true }).selectOption('average'); + await panel.getByRole('button', { name: 'Use this composition' }).first().click(); + await closeSheet(page); + let backup = await projectBackup(page); + expect(backup.source).toEqual(source); + expect(backup.spec.transform).toEqual(transform); + expect(backup.spec.encoding.x.field).toBe('average'); + await importData(page, JSON.stringify(backup)); + await page.getByRole('button', { name: 'Canvas', exact: true }).click(); + await expect( + page.locator('.studio-paper').getByLabel('North: 20', { exact: true }), + ).toBeVisible(); + backup = await projectBackup(page); + expect(backup.source).toEqual(source); + expect(backup.spec.transform).toEqual(transform); +}); + +test('removing a series grouping requires an explicit data decision', async ({ page }) => { + const panel = await inspector(page); + await panel.getByText('Series, labels & small multiples', { exact: true }).click(); + await panel.getByRole('combobox', { name: 'Color by', exact: true }).selectOption(''); + await expect(panel.getByRole('alert')).toContainText('filter or summarize'); + await expect(panel.getByRole('combobox', { name: 'Color by', exact: true })).toHaveValue( + 'company', + ); + await closeSheet(page); + const backup = await projectBackup(page); + expect(backup.spec.encoding.color.field).toBe('company'); +}); diff --git a/e2e/color-history.spec.ts b/e2e/color-history.spec.ts new file mode 100644 index 0000000..f2d3432 --- /dev/null +++ b/e2e/color-history.spec.ts @@ -0,0 +1,68 @@ +import { expect, test, type Page } from '@playwright/test'; + +async function openSeriesControls(page: Page) { + const edit = page.getByRole('button', { name: 'Edit', exact: true }); + if (await edit.isVisible()) await edit.click(); + const inspector = page + .getByRole('dialog', { name: 'Refine your visualization' }) + .or(page.locator('.studio-inspector:visible')); + await inspector.getByRole('tab', { name: 'Design', exact: true }).click(); + const section = inspector.locator('details').filter({ + has: page.locator('summary', { hasText: 'Series, labels & small multiples' }), + }); + if ((await section.getAttribute('open')) === null) + await section.locator('summary').first().click(); + return inspector.getByLabel('Color for Amazon', { exact: true }); +} + +async function closeInspector(page: Page) { + const dialog = page.getByRole('dialog', { name: 'Refine your visualization' }); + if (await dialog.isVisible()) await dialog.getByRole('button', { name: /^Close / }).click(); +} + +test('a native color-picker interaction creates one undoable edit', async ({ page }, info) => { + if (info.project.metadata.cpuThrottle) { + const session = await page.context().newCDPSession(page); + await session.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + } + await page.goto('./'); + await expect(page.getByRole('button', { name: 'Import data' })).toBeVisible(); + const undo = page.getByRole('button', { name: 'Undo', exact: true, includeHidden: true }); + const redo = page.getByRole('button', { name: 'Redo', exact: true, includeHidden: true }); + await expect(undo).toBeDisabled(); + let swatch = await openSeriesControls(page); + const original = await swatch.inputValue(); + const finalColor = '#882244'; + await swatch.focus(); + for (const color of ['#1254a8', '#8452a1', finalColor]) { + // Native pickers emit input repeatedly while dragging. Use the native + // setter so React sees the same events instead of bypassing its value tracker. + await swatch.evaluate((element, value) => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + }, color); + await expect(swatch).toHaveValue(color); + await expect(undo).toBeDisabled(); + } + await swatch.dispatchEvent('change'); + await expect(undo).toBeDisabled(); + await swatch.press('Tab'); + await expect(undo).toBeEnabled(); + await expect(swatch).toHaveValue(finalColor); + // Reopening and leaving the same swatch must not add another history entry. + await swatch.focus(); + await swatch.press('Tab'); + await closeInspector(page); + + await undo.click(); + await expect(undo).toBeDisabled(); + await expect(redo).toBeEnabled(); + swatch = await openSeriesControls(page); + await expect(swatch).toHaveValue(original); + await closeInspector(page); + + await redo.click(); + await expect(redo).toBeDisabled(); + swatch = await openSeriesControls(page); + await expect(swatch).toHaveValue(finalColor); +}); diff --git a/e2e/studio.spec.ts b/e2e/studio.spec.ts new file mode 100644 index 0000000..5a74e71 --- /dev/null +++ b/e2e/studio.spec.ts @@ -0,0 +1,376 @@ +import { expect, test, type Page } from '@playwright/test'; + +async function openInspector(page: Page) { + const edit = page.getByRole('button', { name: 'Edit', exact: true }); + if (await edit.isVisible()) await edit.click(); + return page + .getByRole('dialog', { name: 'Refine your visualization' }) + .or(page.locator('.studio-inspector:visible')); +} + +async function closeSheet(page: Page) { + const sheet = page.getByRole('dialog'); + if (await sheet.isVisible()) await sheet.getByRole('button', { name: /^Close / }).click(); +} + +async function downloadRecovery(page: Page) { + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download recovery draft', exact: true }).click(); + const stream = await (await download).createReadStream(); + const chunks = []; + for await (const chunk of stream!) chunks.push(chunk); + return Buffer.concat(chunks).toString(); +} + +async function choosePattern(page: Page, name: string) { + const patterns = page.getByRole('button', { name: 'Patterns', exact: true }); + if (await patterns.isVisible()) await patterns.click(); + await page + .getByRole('button', { name: new RegExp(name) }) + .filter({ visible: true }) + .click(); +} + +test.beforeEach(async ({ page }, info) => { + if (info.project.metadata.cpuThrottle) { + const session = await page.context().newCDPSession(page); + await session.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + } + await page.goto('./'); + await expect(page.getByRole('button', { name: 'Import data' })).toBeVisible(); + await page.evaluate(() => document.fonts.ready); +}); + +test('all eight compositions render within the viewport and keep stable height', async ({ + page, +}, info) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + const patterns = [ + 'Change over time', + 'A clear comparison', + 'Parts of the whole', + 'Spread & outliers', + 'Before & after', + 'A geographic lens', + 'The details matter', + 'Build a narrative', + ]; + for (const [index, name] of patterns.entries()) { + await choosePattern(page, name); + const paper = page.locator('.studio-paper'); + await expect(paper).toBeVisible(); + await expect(page.getByRole('alert')).toHaveCount(0); + await expect + .poll(async () => paper.evaluate((el) => el.getBoundingClientRect().height)) + .toBeGreaterThan(200); + // The renderer removes its entrance state after the finite settling window. + // Do not await every document Animation.finished: Chromium can suspend those + // promises for sparklines inside closed mobile-table details. + await expect(paper.locator('.oc-animate')).toHaveCount(0); + await page.evaluate(async () => { + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + }); + const height = await paper.evaluate((el) => el.getBoundingClientRect().height); + expect(height).toBeLessThan(1800); + await page.evaluate(async () => { + for (let i = 0; i < 8; i++) + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }); + expect( + Math.abs((await paper.evaluate((el) => el.getBoundingClientRect().height)) - height), + ).toBeLessThan(1); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ), + ).toBeLessThanOrEqual(1); + if (index === 2) + await expect(paper.locator('svg text').filter({ hasText: /^2022$/ })).toBeVisible(); + if (index === 5) await expect(paper.locator('.oc-map-feature').first()).toBeVisible(); + if (index === 0) + await expect(paper.locator('svg text').filter({ hasText: /^2024$/ })).toBeVisible(); + await page.screenshot({ + path: info.outputPath(`composition-${index + 1}.png`), + fullPage: true, + }); + } + expect(errors).toEqual([]); +}); + +test('imports, corrects data, recommends a chart, edits, undoes, reloads, and exports without JSON', async ({ + page, +}) => { + await page.getByRole('button', { name: 'Import data' }).click(); + await page + .getByLabel('Or paste data') + .fill('country,value,code\nJapan,84.7,001\nBrazil,75.8,002'); + await page.getByRole('button', { name: 'Preview your data' }).click(); + await expect(page.getByLabel('code, row 1')).toHaveValue('001'); + await page.getByLabel('Type of value').selectOption('text'); + await expect(page.getByLabel('Type of value')).toHaveValue('text'); + await page.getByLabel('Type of value').selectOption('number'); + await page.getByLabel('value, row 1').fill('85.1'); + await page.getByLabel('value, row 1').press('Tab'); + await expect(page.getByLabel('value, row 1')).toHaveValue('85.1'); + const inspector = await openInspector(page); + await inspector.getByRole('tab', { name: 'Suggest' }).click(); + await inspector + .getByRole('combobox', { name: 'Categories', exact: true }) + .selectOption('country'); + await inspector.getByRole('combobox', { name: 'Measure', exact: true }).selectOption('value'); + await inspector + .getByRole('article') + .filter({ hasText: 'Compare on a shared baseline' }) + .getByRole('button', { name: 'Use this composition' }) + .click(); + await inspector.getByRole('tab', { name: 'Design', exact: true }).click(); + await inspector + .getByRole('textbox', { name: 'Headline', exact: true }) + .fill('A considered comparison'); + await inspector.getByRole('textbox', { name: 'Headline', exact: true }).press('Tab'); + await closeSheet(page); + await page.getByRole('button', { name: 'Canvas', exact: true }).click(); + await expect(page.locator('.studio-paper')).toContainText('A considered comparison'); + await page.getByRole('button', { name: 'Undo', exact: true }).click(); + await expect(page.locator('.studio-paper')).toContainText('What does your data show?'); + await page.getByRole('button', { name: 'Redo', exact: true }).click(); + await expect(page.getByText('Saved in this browser', { exact: true })).toHaveCount(1); + await page.reload(); + await expect(page.locator('.studio-paper')).toContainText('A considered comparison'); + await page.getByLabel('Export format').selectOption('svg'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export ↗', exact: true }).click(); + const exported = await download; + expect(await exported.failure()).toBeNull(); + const stream = await exported.createReadStream(); + const chunks = []; + for await (const chunk of stream!) chunks.push(chunk); + const svg = Buffer.concat(chunks).toString(); + expect(svg).toContain('A considered comparison'); + expect(svg).toContain('85.1'); + expect(svg).not.toContain('NaN'); +}); + +test('invalid imports are actionable and do not replace the current document', async ({ page }) => { + await page.getByRole('button', { name: 'Import data' }).click(); + await page.getByLabel('Or paste data').fill('value,value\n10,20'); + await page.getByRole('button', { name: 'Preview your data' }).click(); + await expect(page.getByRole('alert')).toContainText('unique'); + await closeSheet(page); + await expect(page.locator('.studio-paper')).toContainText('Amazon pulls away'); +}); + +test('keyboard inspection pins exact source values and Escape dismisses them', async ({ page }) => { + const chart = page.locator('.studio-chart'); + await chart.focus(); + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('Enter'); + const details = page.getByRole('region', { name: 'Observation details' }); + await expect(details).toContainText('Pinned evidence'); + await expect(details).toContainText('280'); + await expect(details).toContainText('Amazon'); + await page.keyboard.press('Escape'); + await expect(details).toContainText('Inspect the evidence'); +}); + +test('reduced motion and resize round trips preserve the terminal chart', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.getByRole('button', { name: /Replay/ }).click(); + const svg = page.locator('.studio-paper svg.oc-chart'); + const before = await svg.getAttribute('viewBox'); + await page.getByLabel('Preview width').selectOption('320'); + await expect.poll(() => svg.getAttribute('viewBox')).not.toBeNull(); + await page.getByLabel('Preview width').selectOption('768'); + await expect(svg).toHaveAttribute('viewBox', before!); + await expect(page.locator('.studio-paper svg text').filter({ hasText: /^2024$/ })).toBeVisible(); +}); + +test('mobile inspector choices survive dismissal and data-view exports retain preview geometry', async ({ + page, +}) => { + let inspector = await openInspector(page); + await inspector.getByRole('tab', { name: 'Suggest' }).click(); + await inspector + .getByRole('combobox', { name: 'What do you want to show?' }) + .selectOption('change'); + await inspector.getByRole('combobox', { name: 'Date', exact: true }).selectOption('year'); + await closeSheet(page); + inspector = await openInspector(page); + await expect(inspector.getByRole('combobox', { name: 'Date', exact: true })).toHaveValue('year'); + await closeSheet(page); + const svg = page.locator('.studio-paper svg.oc-chart'); + const width = (await svg.getAttribute('viewBox'))!.split(' ')[2]; + await page.getByRole('button', { name: 'Data', exact: true }).click(); + await page.getByLabel('revenue, row 1', { exact: true }).fill('281'); + await page.getByLabel('revenue, row 1', { exact: true }).press('Tab'); + await expect.poll(async () => (await svg.getAttribute('viewBox'))!.split(' ')[2]).toBe(width); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export ↗', exact: true }).click(); + const stream = await (await download).createReadStream(); + const chunks = []; + for await (const chunk of stream!) chunks.push(chunk); + expect(Buffer.concat(chunks).toString()).toContain(`viewBox="0 0 ${width} `); + await page.getByRole('button', { name: 'Canvas', exact: true }).click(); + const slider = page.getByRole('slider', { name: 'Preview through year' }); + await slider.fill('2'); + await page.getByLabel('Export format').selectOption('csv'); + const csvDownload = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export ↗', exact: true }).click(); + const csvStream = await (await csvDownload).createReadStream(); + const csvChunks = []; + for await (const chunk of csvStream!) csvChunks.push(chunk); + const csv = Buffer.concat(csvChunks).toString(); + expect(csv).toContain('2024-01-01'); + expect(csv.trim().split('\n')).toHaveLength(31); + await slider.fill('5'); + await page.getByRole('button', { name: 'Data', exact: true }).click(); + await expect(page.getByLabel('revenue, row 1', { exact: true })).toHaveValue('281'); +}); + +test('table pins never display stale evidence after corrections or imports', async ({ page }) => { + await choosePattern(page, 'The details matter'); + const paper = page.locator('.studio-paper'); + await expect(paper.locator('table')).toBeVisible(); + await expect(paper.locator('.oc-animate')).toHaveCount(0); + const more = page.locator('.studio-paper summary').first(); + if (page.viewportSize()!.width < 600) { + await expect(more).toBeVisible(); + // Resize can rebuild priority details. Measure both boxes atomically and + // retry missing controls rather than dereferencing a detached element. + await expect + .poll(() => + paper.evaluate((element) => { + const bounds = element.getBoundingClientRect(); + const control = element.querySelector('summary')?.getBoundingClientRect(); + return !!control && control.width > 0 && control.right <= bounds.right; + }), + ) + .toBe(true); + await more.click(); + await expect(page.locator('.studio-paper details[open]')).toContainText('Company'); + await more.click(); + } + await page.locator('.studio-paper').getByText('MSFT', { exact: true }).first().click(); + await expect(page.getByRole('region', { name: 'Observation details' })).toContainText( + 'Pinned evidence', + ); + await page.getByRole('button', { name: 'Data', exact: true }).click(); + await page.getByLabel('price, row 2', { exact: true }).fill('1'); + await page.getByLabel('price, row 2', { exact: true }).press('Tab'); + await page.getByRole('button', { name: 'Canvas', exact: true }).click(); + await expect(page.getByRole('region', { name: 'Observation details' })).not.toContainText( + '378.91', + ); + await expect(page.getByRole('region', { name: 'Observation details' })).toContainText( + 'Inspect the evidence', + ); + await page.locator('.studio-paper').getByText('MSFT', { exact: true }).first().click(); + await expect(page.getByRole('region', { name: 'Observation details' })).toContainText( + 'Pinned evidence', + ); + await page.getByRole('button', { name: 'Import data' }).click(); + await page.getByLabel('Or paste data').fill('name,value\nNew row,7'); + await page.getByRole('button', { name: 'Preview your data' }).click(); + await page.getByRole('button', { name: 'Canvas', exact: true }).click(); + await expect(page.getByRole('region', { name: 'Observation details' })).not.toContainText('MSFT'); +}); + +test('malformed chrome imports and saved drafts are recoverable', async ({ page }) => { + const malformed = JSON.stringify({ + mark: 'bar', + data: [{ name: 'A', value: 1 }], + encoding: { + x: { field: 'name', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + chrome: { title: { text: { bad: true } } }, + }); + await page.getByRole('button', { name: 'Import data' }).click(); + await page.getByLabel('Or paste data').fill(malformed); + await page.getByRole('button', { name: 'Preview your data' }).click(); + await expect(page.getByRole('alert')).toContainText('string text'); + await closeSheet(page); + await expect(page.locator('.studio-paper')).toContainText('Amazon pulls away'); + await page.evaluate((raw) => localStorage.setItem('openchart.studio.draft.v1', raw), malformed); + await page.reload(); + await expect( + page.getByText('The saved draft could not be opened.', { exact: false }), + ).toBeVisible(); + await expect(page.locator('.studio-paper')).toContainText('Amazon pulls away'); + expect(await page.evaluate(() => localStorage.getItem('openchart.studio.draft.v1'))).toBe( + malformed, + ); + await page.getByRole('button', { name: 'Import data' }).click(); + await page.getByLabel('Or paste data').fill('name,value\nRecovered,1'); + await page.getByRole('button', { name: 'Preview your data' }).click(); + await expect(page.getByLabel('name, row 1', { exact: true })).toHaveValue('Recovered'); + await expect(page.getByText('Saved in this browser', { exact: true })).toHaveCount(1); + expect(await downloadRecovery(page)).toBe(malformed); + await page.reload(); + await page.getByRole('button', { name: 'Data', exact: true }).click(); + await expect(page.getByLabel('name, row 1', { exact: true })).toHaveValue('Recovered'); + expect(await downloadRecovery(page)).toBe(malformed); +}); + +test('failed recovery preservation keeps the original through edits and reloads', async ({ page }) => { + const raw = ' { unreadable original draft\n'; + await page.evaluate((value) => localStorage.setItem('openchart.studio.draft.v1', value), raw); + await page.addInitScript(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (key, value) { + if (key.startsWith('openchart.studio.draft.v1.recovery.')) + throw new DOMException('Storage quota exceeded', 'QuotaExceededError'); + return setItem.call(this, key, value); + }; + }); + await page.reload(); + await expect(page.getByText('This browser could not preserve a recovery copy.', { exact: false })).toBeVisible(); + await page.getByRole('button', { name: 'Reopen draft', exact: true }).click(); + await expect(page.getByText('This browser could not preserve a recovery copy.', { exact: false })).toBeVisible(); + await page.getByRole('button', { name: 'Import data' }).click(); + await page.getByLabel('Or paste data').fill('name,value\nNew work,2'); + await page.getByRole('button', { name: 'Preview your data' }).click(); + await expect(page.getByLabel('name, row 1', { exact: true })).toHaveValue('New work'); + // Page-hide flushes must use the same protection as the debounced autosave. + await page.evaluate(() => window.dispatchEvent(new Event('pagehide'))); + await expect(page.getByText('Automatic saving is disabled to protect the original draft.', { exact: false })).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('openchart.studio.draft.v1'))).toBe(raw); + expect(await downloadRecovery(page)).toBe(raw); + await page.reload(); + await expect(page.getByText('This browser could not preserve a recovery copy.', { exact: false })).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('openchart.studio.draft.v1'))).toBe(raw); + expect(await downloadRecovery(page)).toBe(raw); +}); + +test('map PNG exports preserve the authored background and meaningful country details', async ({ + page, +}) => { + await choosePattern(page, 'A geographic lens'); + await page + .getByRole('listitem', { name: 'United States of America: $80,035', exact: true }) + .click(); + const details = page.getByRole('region', { name: 'Observation details' }); + await expect(details).toContainText('United States of America'); + await expect(details).toContainText('GDP per capita (USD)'); + await page.getByLabel('Export format').selectOption('png'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export ↗', exact: true }).click(); + const stream = await (await download).createReadStream(); + const chunks = []; + for await (const chunk of stream!) chunks.push(chunk); + const pixel = await page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext('2d')!; + context.drawImage(image, 0, 0); + return Array.from(context.getImageData(1, 1, 1, 1).data); + }, Buffer.concat(chunks).toString('base64')); + expect(pixel).toEqual([255, 255, 255, 255]); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..077fc74 --- /dev/null +++ b/index.html @@ -0,0 +1,15 @@ + + + + + + + + + OpenStudio · OpenChart authoring demo + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..eafabd8 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "@opendata-ai/openstudio", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 6007 --strictPort", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1 --port 6008 --strictPort", + "typecheck": "tsc --noEmit", + "lint": "biome check src", + "test": "vitest run", + "test:e2e": "playwright test" + }, + "dependencies": { + "@opendata-ai/openchart-core": "8.9.0", + "@opendata-ai/openchart-engine": "8.9.0", + "@opendata-ai/openchart-vanilla": "8.9.0", + "d3-dsv": "3.0.1", + "motion": "14.1.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "world-atlas": "2.0.2", + "gifenc": "1.0.3" + }, + "devDependencies": { + "@biomejs/biome": "2.4.4", + "@playwright/test": "1.59.1", + "@types/d3-dsv": "3.0.7", + "@types/node": "24.19.2", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "typescript": "7.0.2", + "vite": "7.3.1", + "vitest": "5.0.4" + }, + "description": "Browser-only visualization authoring powered by OpenChart", + "license": "Apache-2.0", + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/tryopendata/openstudio.git" + }, + "homepage": "https://tryopendata.github.io/openstudio/", + "packageManager": "bun@1.4.2" +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d05a0d6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from '@playwright/test'; + +/** Exercise the deployable artifact, including WebKit and a throttled phone. */ +export default defineConfig({ + testDir: './e2e', + outputDir: './test-results', + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 45_000, + reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], + use: { + baseURL: `http://127.0.0.1:6008${process.env.STUDIO_TEST_BASE_PATH || '/'}`, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + deviceScaleFactor: 1, + }, + projects: [ + { + name: 'studio-desktop', + use: { browserName: 'chromium', viewport: { width: 1280, height: 900 } }, + }, + { + name: 'studio-phone', + use: { browserName: 'chromium', viewport: { width: 390, height: 844 }, hasTouch: true }, + metadata: { cpuThrottle: 4 }, + }, + { + name: 'studio-narrow', + use: { browserName: 'chromium', viewport: { width: 320, height: 740 }, hasTouch: true }, + }, + { + name: 'studio-webkit', + use: { browserName: 'webkit', viewport: { width: 390, height: 844 }, hasTouch: true }, + }, + ], + webServer: { + command: 'bun run build && node scripts/serve-dist.mjs', + url: `http://127.0.0.1:6008${process.env.STUDIO_TEST_BASE_PATH || '/'}`, + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/public/Inter-LICENSE.txt b/public/Inter-LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/public/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/LICENSE.txt b/public/LICENSE.txt new file mode 100644 index 0000000..82f4672 --- /dev/null +++ b/public/LICENSE.txt @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright 2025 Riley Hilliard + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/public/NOTICE.txt b/public/NOTICE.txt new file mode 100644 index 0000000..b838339 --- /dev/null +++ b/public/NOTICE.txt @@ -0,0 +1,13 @@ +OpenStudio + +OpenStudio originates from the OpenChart Studio authoring application in +https://github.com/tryopendata/openchart and uses the public OpenChart packages. +Application source is distributed under the Apache License, Version 2.0. +See LICENSE.txt for the full license and copyright notice. + +Inter font +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) +The bundled Inter font is distributed under the SIL Open Font License, +Version 1.1. See Inter-LICENSE.txt in this static artifact. + +Other third-party package licenses remain with their respective packages. diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..75522d3 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/scripts/serve-dist.mjs b/scripts/serve-dist.mjs new file mode 100644 index 0000000..049f43a --- /dev/null +++ b/scripts/serve-dist.mjs @@ -0,0 +1,28 @@ +/** Local QA server that proves the artifact works under a real nested mount. */ +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, resolve, sep } from 'node:path'; +const root = resolve('dist'); +const base = process.env.STUDIO_TEST_BASE_PATH || '/'; +const types = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.woff2': 'font/woff2', + '.json': 'application/json', + '.svg': 'image/svg+xml', +}; +createServer(async (request, response) => { + try { + const path = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); + if (!path.startsWith(base)) throw new Error('Outside mount'); + const file = resolve(root, path.slice(base.length) || 'index.html'); + if (!file.startsWith(root + sep)) throw new Error('Outside artifact'); + const contents = await readFile(file); + response.writeHead(200, { 'Content-Type': types[extname(file)] ?? 'application/octet-stream' }); + response.end(contents); + } catch { + response.writeHead(404); + response.end('Not found'); + } +}).listen(6008, '127.0.0.1', () => console.log(`Studio QA: http://127.0.0.1:6008${base}`)); diff --git a/src/AuthoringDrafts.tsx b/src/AuthoringDrafts.tsx new file mode 100644 index 0000000..f2f45df --- /dev/null +++ b/src/AuthoringDrafts.tsx @@ -0,0 +1,35 @@ +import type { DataRow } from '@opendata-ai/openchart-core'; +import { + createContext, + type Dispatch, + type ReactNode, + type SetStateAction, + useContext, + useEffect, + useState, +} from 'react'; + +type Drafts = Record; +const Context = createContext<{ + drafts: Drafts; + setDrafts: Dispatch>; +} | null>(null); + +/** Both desktop and sheet inspectors edit the same unfinished, document-scoped choices. */ +export function AuthoringDrafts({ source, children }: { source: DataRow[]; children: ReactNode }) { + const [drafts, setDrafts] = useState({}); + // A replacement source invalidates pending roles and anchors from the old document. + useEffect(() => { + void source; + setDrafts({}); + }, [source]); + return {children}; +} +export function useAuthoringDraft(key: string, initial: T): [T, (next: T) => void] { + const context = useContext(Context); + if (!context) throw new Error('Authoring controls require a Studio document.'); + return [ + (context.drafts[key] as T | undefined) ?? initial, + (next) => context.setDrafts((current) => ({ ...current, [key]: next })), + ]; +} diff --git a/src/ChartControls.tsx b/src/ChartControls.tsx new file mode 100644 index 0000000..1223599 --- /dev/null +++ b/src/ChartControls.tsx @@ -0,0 +1,542 @@ +import type { + Annotation, + ChartSpec, + Encoding, + EncodingChannel, + LabelSpec, +} from '@opendata-ai/openchart-core'; +import { useMemo, useState } from 'react'; +import { useAuthoringDraft } from './AuthoringDrafts'; +import { + assertCompositionObservations, + type Composition, + type CompositionRoles, + compose, + compositionNames, + encodingFields, + encodingRows, + fieldChannel, +} from './composition'; +import type { SupportedSpec } from './Preview'; +import { changeSeriesColor, seriesColors } from './series-style'; + +export function CompositionControl({ + spec, + onChange, +}: { + spec: SupportedSpec; + onChange: (spec: SupportedSpec) => void; +}) { + const fields = encodingFields(spec); + const chart = !('type' in spec) ? spec : null; + const [kind, setKind] = useAuthoringDraft('composition.kind', 'bar'); + const [roles, setRoles] = useAuthoringDraft('composition.roles', { + x: chart?.encoding.x?.field ?? fields[0] ?? '', + y: chart?.encoding.y?.field ?? fields[1] ?? '', + series: + chart?.encoding.color && 'field' in chart.encoding.color ? chart.encoding.color.field : '', + end: chart?.encoding.x2?.field ?? '', + }); + const [error, setError] = useState(''); + return ( +
+ Change composition +

+ Keep your data, headline and source. Choose roles explicitly. Axes, labels and annotations + are reset for the new composition; Undo restores them. Lines use dates or ordered categories + along X. +

+ + {kind !== 'table' && + ( + [ + 'x', + ...(kind === 'histogram' ? [] : ['y']), + 'series', + ...(kind === 'range' ? ['end'] : []), + ] as Array + ).map((role) => ( + + ))} + {error && ( +

+ {error} +

+ )} + +
+ ); +} +const formats = [ + ['', 'Automatic'], + [',.0f', 'Whole numbers'], + [',.2f', 'Two decimals'], + ['.1%', 'Percent (fraction × 100)'], + ['.1f', 'Percentage values (0–100)'], + ['$,.0f', 'US dollars'], + ['.3~s', 'Compact'], +]; +export function NumberFormat({ + value, + onChange, + label = 'Number format', +}: { + value?: string; + onChange: (value: string | undefined) => void; + label?: string; +}) { + return ( + + ); +} +export function ChartControls({ + chart, + onChange, +}: { + chart: ChartSpec; + onChange: (spec: ChartSpec) => void; +}) { + const fields = encodingFields(chart); + const mark = typeof chart.mark === 'string' ? chart.mark : chart.mark.type; + const [error, setError] = useState(''); + const setEncoding = (encoding: Encoding) => { + try { + const roles = (value: Encoding) => + ['x', 'y', 'color', 'facet', 'row', 'column'].map((key) => { + const channel = value[key as keyof Encoding]; + return channel && typeof channel === 'object' && 'field' in channel + ? [channel.field, 'type' in channel ? channel.type : undefined] + : null; + }); + if (JSON.stringify(roles(encoding)) !== JSON.stringify(roles(chart.encoding))) + assertCompositionObservations(mark, encoding, encodingRows(chart)); + onChange({ ...chart, encoding } as ChartSpec); + setError(''); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }; + const setChannel = (axis: 'x' | 'y', channel: EncodingChannel) => + setEncoding({ ...chart.encoding, [axis]: channel }); + const color = + chart.encoding.color && 'field' in chart.encoding.color ? chart.encoding.color : null; + const colors = useMemo(() => seriesColors(chart), [chart]); + const observations = useMemo(() => encodingRows(chart), [chart]); + const series = color + ? [...new Set(observations.map((row) => String(row[color.field] ?? '')))] + : []; + return ( + <> + {error && ( +

+ {error} +

+ )} +
+ Fields & axes + {(['x', 'y'] as const).map((axis) => { + const channel = chart.encoding[axis]; + if (!channel) return null; + const config = typeof channel.axis === 'object' && channel.axis ? channel.axis : {}; + return ( +
+ +
+ {axis.toUpperCase()} axis options + + + {channel.type === 'quantitative' ? ( + <> + + setChannel(axis, { ...channel, format, axis: { ...config, format } }) + } + /> + + + ) : channel.type === 'nominal' || channel.type === 'ordinal' ? ( + + ) : null} +
+
+ ); + })} + {mark === 'range' && ( + + )} +
+
+ Series, labels & small multiples + + {color?.type === 'nominal' && + series.length <= 20 && + series + .filter((name) => colors.has(name)) + .map((name) => ( + + ))} + + + {mark === 'area' && ( + + )} +
+ + + ); +} +function AnnotationControls({ + chart, + onChange, +}: { + chart: ChartSpec; + onChange: (spec: ChartSpec) => void; +}) { + const [kind, setKind] = useAuthoringDraft<'refline' | 'text'>('annotation.kind', 'refline'); + const [axis, setAxis] = useAuthoringDraft<'x' | 'y'>('annotation.axis', 'y'); + const [x, setX] = useAuthoringDraft('annotation.x', ''); + const [y, setY] = useAuthoringDraft('annotation.y', ''); + const [text, setText] = useAuthoringDraft('annotation.text', ''); + const [error, setError] = useState(''); + const coordinate = (key: 'x' | 'y', value: string) => { + if (!value.trim()) throw new Error('Enter an anchor value.'); + if (chart.encoding[key]?.type === 'quantitative') { + if (!Number.isFinite(Number(value))) + throw new Error('Use a numeric anchor on a numeric axis.'); + return Number(value); + } + return value; + }; + return ( +
+ Annotations & reference lines + {chart.annotations?.map((annotation, index) => ( +
+ + {annotation.type === 'text' ? annotation.text : (annotation.label ?? annotation.type)} + + +
+ ))} + + {kind === 'refline' && ( + + )} + {(kind === 'text' || axis === 'x') && ( + + )} + {(kind === 'text' || axis === 'y') && ( + + )} + + {error &&

{error}

} + +
+ ); +} diff --git a/src/DataEditor.tsx b/src/DataEditor.tsx new file mode 100644 index 0000000..de55eff --- /dev/null +++ b/src/DataEditor.tsx @@ -0,0 +1,124 @@ +import type { DataRow, FieldMetadata } from '@opendata-ai/openchart-core'; +import { useState } from 'react'; +import { coerceField, type FieldType, fieldType } from './document'; + +export function DataEditor({ + data, + fields, + onChange, +}: { + data: DataRow[]; + fields?: Record; + onChange: (rows: DataRow[], fields?: Record) => void; +}) { + const [error, setError] = useState(''); + const [page, setPage] = useState(0); + const columns = [...new Set(data.flatMap(Object.keys))].filter( + (key) => fields?.[key]?.role !== 'identity', + ); + const start = Math.min(page * 20, Math.max(0, Math.floor((data.length - 1) / 20) * 20)); + const changeType = (key: string, type: FieldType) => { + try { + onChange( + data.map((row) => ({ ...row, [key]: coerceField(row[key], type) })), + { ...fields, [key]: { ...fields?.[key], type } }, + ); + setError(''); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }; + return ( +
+
+

Your data

+ + {data.length} rows · {columns.length} fields + +
+

+ Edit a value or correct a field type. Blank cells remain missing observations. +

+ {error && ( +

+ {error} +

+ )} +
+ + + + {columns.map((key) => ( + + ))} + + + + {data.slice(start, start + 20).map((row, index) => ( + + {columns.map((key) => ( + + ))} + + ))} + +
+ {key} + +
+ {Array.isArray(row[key]) ? ( + {JSON.stringify(row[key])} + ) : ( + { + if (event.target.value === String(row[key] ?? '')) return; + try { + const next = [...data]; + next[start + index] = { + ...row, + [key]: coerceField(event.target.value, fieldType(data, key, fields)), + }; + onChange(next, fields); + setError(''); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } + }} + /> + )} +
+
+ {!data.length &&

No observations. Import data to start a visualization.

} +
+ + + {data.length ? start + 1 : 0}–{Math.min(start + 20, data.length)} of {data.length} + + +
+
+ ); +} diff --git a/src/Inspector.tsx b/src/Inspector.tsx new file mode 100644 index 0000000..900ca1c --- /dev/null +++ b/src/Inspector.tsx @@ -0,0 +1,368 @@ +import type { + DesignDiagnostic, + DesignQuestion, + SpecPatch, + VizSpec, +} from '@opendata-ai/openchart-core'; +import { type RecommendationRoles, recommendCompositions } from '@opendata-ai/openchart-engine'; +import { motion } from 'motion/react'; +import { useId } from 'react'; +import { ChartControls, CompositionControl } from './ChartControls'; +import { encodingFields, encodingRows } from './composition'; +import { isSupported, type SupportedSpec } from './Preview'; +import { MapControls, TableControls } from './TableMapControls'; + +type InspectorTab = 'design' | 'recommend' | 'review'; + +/** Panels stay mounted so changing tabs never discards chosen field roles. */ +export function Inspector({ + spec, + tab, + onTabChange, + onChange, + diagnostics, + onPreview, + question, + setQuestion, + roles, + setRoles, +}: { + spec: SupportedSpec; + tab: InspectorTab; + onTabChange: (tab: InspectorTab) => void; + onChange: (spec: VizSpec) => void; + diagnostics: DesignDiagnostic[]; + onPreview: (patch: SpecPatch) => void; + question: DesignQuestion; + setQuestion: (question: DesignQuestion) => void; + roles: RecommendationRoles; + setRoles: (roles: RecommendationRoles) => void; +}) { + const id = useId(); + const tabs = ['design', 'recommend', 'review'] as const; + return ( + <> +
{ + const direction = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0; + if (!direction && event.key !== 'Home' && event.key !== 'End') return; + event.preventDefault(); + const index = + event.key === 'Home' + ? 0 + : event.key === 'End' + ? 2 + : (tabs.indexOf(tab) + direction + tabs.length) % tabs.length; + onTabChange(tabs[index]); + (event.currentTarget.querySelectorAll('button')[index] as HTMLButtonElement).focus(); + }} + > + {tabs.map((name) => ( + + ))} +
+ {tabs.map((name) => ( + + ))} + + ); +} + +function chromeText(spec: SupportedSpec, key: 'title' | 'subtitle' | 'source' | 'footer'): string { + const value = spec.chrome?.[key]; + return typeof value === 'string' ? value : (value?.text ?? ''); +} +export function DesignInspector({ + spec, + onChange, +}: { + spec: SupportedSpec; + onChange: (next: VizSpec) => void; +}) { + const chart = !('type' in spec) ? spec : null; + return ( +
+

Make the message clear

+

Start with what readers should understand.

+ {(['title', 'subtitle', 'source', 'footer'] as const).map((key) => ( +