From cde89cbc5b96a353c9a896a861125340effa5984 Mon Sep 17 00:00:00 2001 From: David Longworth Date: Tue, 18 Aug 2026 13:05:22 +0100 Subject: [PATCH 001/271] feat: 2026 results site with experimental data format --- .prettierignore | 1 + package-lock.json | 945 ++++++------------ packages/archive/index.json | 24 +- packages/results/.env.example | 3 + packages/results/.gitignore | 3 + packages/results/README.md | 73 ++ .../{src/lib/constants.ts => config.ts} | 56 +- packages/results/eslint.config.js | 8 +- packages/results/netlify.toml | 113 +++ packages/results/package.json | 19 +- packages/results/scripts/data.js | 367 +++++++ packages/results/scripts/gsheet.js | 99 ++ packages/results/src/app.d.ts | 7 +- .../src/lib/charts/ChartDownload.svelte | 135 +++ .../results/src/lib/charts/ChartRoot.svelte | 20 + packages/results/src/lib/charts/Hero.svelte | 61 ++ .../results/src/lib/charts/Highlight.svelte | 36 + .../results/src/lib/charts/Tooltip.svelte | 47 + .../charts/hero/BarStackedHorizontal.svelte | 86 ++ .../lib/charts/hero/BarStackedMinmax.svelte | 82 ++ .../lib/charts/hero/BarStackedVertical.svelte | 82 ++ .../results/src/lib/charts/hero/Cube.svelte | 55 + .../results/src/lib/charts/hero/Rank.svelte | 91 ++ .../results/src/lib/charts/hero/Stat.svelte | 44 + .../src/lib/charts/hero/Treemap.svelte | 60 ++ .../src/lib/charts/hero/WaffleLarge.svelte | 54 + .../src/lib/charts/hero/WaffleMedium.svelte | 51 + .../highlight/BarStackedHorizontal.svelte | 86 ++ .../charts/highlight/BarStackedMinmax.svelte | 84 ++ .../highlight/BarStackedVertical.svelte | 82 ++ .../src/lib/charts/highlight/Rank.svelte | 67 ++ .../src/lib/charts/highlight/Treemap.svelte | 47 + .../lib/charts/highlight/TreemapSmall.svelte | 46 + .../lib/charts/highlight/WaffleLarge.svelte | 41 + .../lib/charts/highlight/WaffleMedium.svelte | 42 + packages/results/src/lib/charts/index.ts | 71 ++ .../src/lib/charts/standard/Bar.svelte | 138 +++ .../lib/charts/standard/BarClustered.svelte | 140 +++ .../src/lib/charts/standard/BarStacked.svelte | 146 +++ .../src/lib/charts/standard/Dumbbell.svelte | 175 ++++ .../src/lib/charts/standard/Sankey.svelte | 176 ++++ .../src/lib/charts/standard/Scatter.svelte | 214 ++++ .../src/lib/charts/standard/Table.svelte | 121 +++ .../src/lib/charts/svg/Attribution.svelte | 31 + .../results/src/lib/charts/svg/Glyph.svelte | 15 + .../results/src/lib/charts/svg/Header.svelte | 58 ++ .../results/src/lib/charts/svg/Legend.svelte | 20 + .../results/src/lib/charts/svg/Stats.svelte | 51 + .../results/src/lib/charts/svg/Wrap.svelte | 102 ++ .../src/lib/charts/text/DataTable.svelte | 86 ++ .../results/src/lib/charts/text/Quote.svelte | 21 + .../results/src/lib/charts/text/Stat.svelte | 26 + .../results/src/lib/charts/utils/chrome.ts | 94 ++ .../results/src/lib/charts/utils/export.ts | 140 +++ .../src/lib/charts/utils/expressive.ts | 64 ++ packages/results/src/lib/charts/utils/iso.ts | 35 + .../src/lib/charts/utils/rows.svelte.ts | 75 ++ .../results/src/lib/charts/utils/theme.ts | 160 +++ .../results/src/lib/charts/utils/tooltip.ts | 18 + .../src/lib/components/BrandHeader.svelte | 19 +- .../src/lib/components/ButtonMenu.svelte | 117 +++ .../src/lib/components/ChapterHeader.svelte | 138 +++ .../src/lib/components/ChapterNextPrev.svelte | 44 + .../src/lib/components/CopyPage.svelte | 37 + .../src/lib/components/DataExport.svelte | 90 ++ .../src/lib/components/Demographics.svelte | 77 ++ .../results/src/lib/components/Figure.svelte | 65 ++ .../results/src/lib/components/Footer.svelte | 22 +- .../results/src/lib/components/Icon.svelte | 1 - .../src/lib/components/NavToggle.svelte | 31 + .../src/lib/components/Navigation.svelte | 135 +++ .../src/lib/components/PromoBanner.svelte | 22 - .../src/lib/components/QuestionPanel.svelte | 73 ++ .../src/lib/{seo => components}/Seo.svelte | 25 +- .../src/lib/components/WhatWeAsked.svelte | 46 + packages/results/src/lib/jsonld.ts | 260 +++++ packages/results/src/lib/labels.ts | 40 + packages/results/src/lib/markdown.ts | 2 - packages/results/src/lib/panel.ts | 14 + packages/results/src/lib/seo/JsonLd.svelte | 17 - packages/results/src/lib/seo/graph.ts | 97 -- packages/results/src/lib/seo/index.ts | 3 - packages/results/src/lib/server/content.ts | 26 + packages/results/src/lib/server/llms.ts | 287 ++++++ packages/results/src/lib/table.ts | 79 ++ packages/results/src/routes/+error.svelte | 6 +- packages/results/src/routes/+layout.svelte | 26 +- packages/results/src/routes/+page.server.ts | 3 +- packages/results/src/routes/+page.svelte | 39 +- .../results/src/routes/[year].md/+server.ts | 9 + .../src/routes/[year]/+layout.server.ts | 10 + .../results/src/routes/[year]/+layout.svelte | 12 + .../results/src/routes/[year]/+page.server.ts | 7 + .../results/src/routes/[year]/+page.svelte | 39 + .../routes/[year]/[chapter]/+page.server.ts | 18 + .../src/routes/[year]/[chapter]/+page.svelte | 56 ++ .../[year]/[chapter]/data.md/+server.ts | 9 + .../[year]/[chapter]/data/+page.server.ts | 15 + .../routes/[year]/[chapter]/data/+page.svelte | 163 +++ .../[chapter]/data/[question].md/+server.ts | 9 + .../[chapter]/data/[question]/+page.server.ts | 16 + .../[chapter]/data/[question]/+page.svelte | 228 +++++ .../src/routes/[year]/[page].md/+server.ts | 11 + .../routes/[year]/methodology/+page.server.ts | 6 + .../routes/[year]/methodology/+page.svelte | 12 + .../results/src/routes/index.md/+server.ts | 2 +- packages/results/src/routes/layout.css | 15 + .../results/src/routes/llms.txt/+server.ts | 73 ++ .../results/src/routes/sitemap.xml/+server.ts | 2 +- packages/results/static/bg-chapter-hero.svg | 58 ++ packages/results/tsconfig.json | 7 +- packages/results/vite.config.js | 47 +- packages/survey/src/lib/types/generated.ts | 4 + questions/README.md | 12 + questions/ai/AIComplex.yaml | 15 +- questions/ai/AIImpact.yaml | 2 +- questions/ai/AgentUsesGeneral.yaml | 12 +- questions/compensation/Country.yaml | 15 +- questions/education-career/Education.yaml | 12 +- questions/education-career/Employment.yaml | 1 + .../education-career/EmploymentAddl.yaml | 9 +- questions/education-career/LearnAIHow.yaml | 24 +- questions/education-career/OrgSize.yaml | 3 +- questions/education-career/Role.yaml | 36 +- questions/education-career/WorkSetting.yaml | 9 +- questions/question.schema.json | 6 + questions/retired/AIAgentChallenges.yaml | 18 +- questions/retired/AIAgentChange.yaml | 3 +- questions/retired/AIAgentImpact.yaml | 21 +- questions/retired/AIAgentObserveSecure.yaml | 3 +- questions/retired/AIAgents.yaml | 6 +- questions/retired/AIFrustration.yaml | 15 +- questions/retired/AIHuman.yaml | 27 +- questions/retired/AILearnHow.yaml | 24 +- questions/retired/AITool.yaml | 9 +- questions/retired/DevType.yaml | 30 +- questions/retired/EdLevel.yaml | 18 +- questions/retired/MainBranch.yaml | 9 +- questions/retired/PurchaseInfluence.yaml | 12 +- questions/retired/SOPartFreq.yaml | 15 +- questions/retired/SO_Actions.yaml | 12 +- questions/retired/SO_Dev_Content.yaml | 6 +- 142 files changed, 7321 insertions(+), 1026 deletions(-) create mode 100644 packages/results/.env.example rename packages/results/{src/lib/constants.ts => config.ts} (63%) create mode 100644 packages/results/netlify.toml create mode 100644 packages/results/scripts/data.js create mode 100644 packages/results/scripts/gsheet.js create mode 100644 packages/results/src/lib/charts/ChartDownload.svelte create mode 100644 packages/results/src/lib/charts/ChartRoot.svelte create mode 100644 packages/results/src/lib/charts/Hero.svelte create mode 100644 packages/results/src/lib/charts/Highlight.svelte create mode 100644 packages/results/src/lib/charts/Tooltip.svelte create mode 100644 packages/results/src/lib/charts/hero/BarStackedHorizontal.svelte create mode 100644 packages/results/src/lib/charts/hero/BarStackedMinmax.svelte create mode 100644 packages/results/src/lib/charts/hero/BarStackedVertical.svelte create mode 100644 packages/results/src/lib/charts/hero/Cube.svelte create mode 100644 packages/results/src/lib/charts/hero/Rank.svelte create mode 100644 packages/results/src/lib/charts/hero/Stat.svelte create mode 100644 packages/results/src/lib/charts/hero/Treemap.svelte create mode 100644 packages/results/src/lib/charts/hero/WaffleLarge.svelte create mode 100644 packages/results/src/lib/charts/hero/WaffleMedium.svelte create mode 100644 packages/results/src/lib/charts/highlight/BarStackedHorizontal.svelte create mode 100644 packages/results/src/lib/charts/highlight/BarStackedMinmax.svelte create mode 100644 packages/results/src/lib/charts/highlight/BarStackedVertical.svelte create mode 100644 packages/results/src/lib/charts/highlight/Rank.svelte create mode 100644 packages/results/src/lib/charts/highlight/Treemap.svelte create mode 100644 packages/results/src/lib/charts/highlight/TreemapSmall.svelte create mode 100644 packages/results/src/lib/charts/highlight/WaffleLarge.svelte create mode 100644 packages/results/src/lib/charts/highlight/WaffleMedium.svelte create mode 100644 packages/results/src/lib/charts/index.ts create mode 100644 packages/results/src/lib/charts/standard/Bar.svelte create mode 100644 packages/results/src/lib/charts/standard/BarClustered.svelte create mode 100644 packages/results/src/lib/charts/standard/BarStacked.svelte create mode 100644 packages/results/src/lib/charts/standard/Dumbbell.svelte create mode 100644 packages/results/src/lib/charts/standard/Sankey.svelte create mode 100644 packages/results/src/lib/charts/standard/Scatter.svelte create mode 100644 packages/results/src/lib/charts/standard/Table.svelte create mode 100644 packages/results/src/lib/charts/svg/Attribution.svelte create mode 100644 packages/results/src/lib/charts/svg/Glyph.svelte create mode 100644 packages/results/src/lib/charts/svg/Header.svelte create mode 100644 packages/results/src/lib/charts/svg/Legend.svelte create mode 100644 packages/results/src/lib/charts/svg/Stats.svelte create mode 100644 packages/results/src/lib/charts/svg/Wrap.svelte create mode 100644 packages/results/src/lib/charts/text/DataTable.svelte create mode 100644 packages/results/src/lib/charts/text/Quote.svelte create mode 100644 packages/results/src/lib/charts/text/Stat.svelte create mode 100644 packages/results/src/lib/charts/utils/chrome.ts create mode 100644 packages/results/src/lib/charts/utils/export.ts create mode 100644 packages/results/src/lib/charts/utils/expressive.ts create mode 100644 packages/results/src/lib/charts/utils/iso.ts create mode 100644 packages/results/src/lib/charts/utils/rows.svelte.ts create mode 100644 packages/results/src/lib/charts/utils/theme.ts create mode 100644 packages/results/src/lib/charts/utils/tooltip.ts create mode 100644 packages/results/src/lib/components/ButtonMenu.svelte create mode 100644 packages/results/src/lib/components/ChapterHeader.svelte create mode 100644 packages/results/src/lib/components/ChapterNextPrev.svelte create mode 100644 packages/results/src/lib/components/CopyPage.svelte create mode 100644 packages/results/src/lib/components/DataExport.svelte create mode 100644 packages/results/src/lib/components/Demographics.svelte create mode 100644 packages/results/src/lib/components/Figure.svelte create mode 100644 packages/results/src/lib/components/NavToggle.svelte create mode 100644 packages/results/src/lib/components/Navigation.svelte delete mode 100644 packages/results/src/lib/components/PromoBanner.svelte create mode 100644 packages/results/src/lib/components/QuestionPanel.svelte rename packages/results/src/lib/{seo => components}/Seo.svelte (62%) create mode 100644 packages/results/src/lib/components/WhatWeAsked.svelte create mode 100644 packages/results/src/lib/jsonld.ts create mode 100644 packages/results/src/lib/labels.ts delete mode 100644 packages/results/src/lib/markdown.ts create mode 100644 packages/results/src/lib/panel.ts delete mode 100644 packages/results/src/lib/seo/JsonLd.svelte delete mode 100644 packages/results/src/lib/seo/graph.ts delete mode 100644 packages/results/src/lib/seo/index.ts create mode 100644 packages/results/src/lib/server/content.ts create mode 100644 packages/results/src/lib/server/llms.ts create mode 100644 packages/results/src/lib/table.ts create mode 100644 packages/results/src/routes/[year].md/+server.ts create mode 100644 packages/results/src/routes/[year]/+layout.server.ts create mode 100644 packages/results/src/routes/[year]/+layout.svelte create mode 100644 packages/results/src/routes/[year]/+page.server.ts create mode 100644 packages/results/src/routes/[year]/+page.svelte create mode 100644 packages/results/src/routes/[year]/[chapter]/+page.server.ts create mode 100644 packages/results/src/routes/[year]/[chapter]/+page.svelte create mode 100644 packages/results/src/routes/[year]/[chapter]/data.md/+server.ts create mode 100644 packages/results/src/routes/[year]/[chapter]/data/+page.server.ts create mode 100644 packages/results/src/routes/[year]/[chapter]/data/+page.svelte create mode 100644 packages/results/src/routes/[year]/[chapter]/data/[question].md/+server.ts create mode 100644 packages/results/src/routes/[year]/[chapter]/data/[question]/+page.server.ts create mode 100644 packages/results/src/routes/[year]/[chapter]/data/[question]/+page.svelte create mode 100644 packages/results/src/routes/[year]/[page].md/+server.ts create mode 100644 packages/results/src/routes/[year]/methodology/+page.server.ts create mode 100644 packages/results/src/routes/[year]/methodology/+page.svelte create mode 100644 packages/results/src/routes/llms.txt/+server.ts create mode 100644 packages/results/static/bg-chapter-hero.svg diff --git a/.prettierignore b/.prettierignore index 13ec74b..0a14f85 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,6 +16,7 @@ packages/results/static/ # Generated by `npm run content -w results`, not checked in packages/results/src/content/ packages/results/src/data/ +packages/results/src/generated/ # Historical result sites, kept as published packages/archive/ diff --git a/package-lock.json b/package-lock.json index 1a206b6..a0ed716 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ ], "dependencies": { "@types/lodash-es": "^4.17.12", + "lightningcss-darwin-arm64": "1.32.0", "lodash-es": "^4.18.1" }, "devDependencies": { @@ -22,6 +23,9 @@ "prettier": "^3.9.6", "prettier-plugin-svelte": "^4.1.1", "prettier-plugin-tailwindcss": "^0.8.1" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "npm:null@*" } }, "node_modules/@apidevtools/json-schema-ref-parser": { @@ -1325,9 +1329,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1345,9 +1346,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1365,9 +1363,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1385,9 +1380,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1405,9 +1397,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1425,9 +1414,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1445,9 +1431,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1465,9 +1448,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1485,9 +1465,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1511,9 +1488,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1537,9 +1511,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1563,9 +1534,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1589,9 +1557,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1615,9 +1580,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1641,9 +1603,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1667,9 +1626,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -4901,296 +4857,6 @@ "url": "https://opencollective.com/preact" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", @@ -5555,9 +5221,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5575,9 +5238,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5595,9 +5255,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5615,9 +5272,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5772,6 +5426,64 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", + "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -8417,28 +8129,190 @@ "dev": true, "license": "MIT", "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/cyclist": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.2.tgz", + "integrity": "sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">=12" } }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/cyclist": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.2.tgz", - "integrity": "sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==", - "dev": true, - "license": "MIT" - }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -11780,6 +11654,12 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -13030,236 +12910,8 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } + "optional": true }, "node_modules/lilconfig": { "version": "2.1.0", @@ -16843,6 +16495,13 @@ "svelte": "^5.7.0" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -19860,6 +19519,10 @@ "dependencies": { "@stackoverflow/stacks-icons": "^7.0.0-beta.28", "clsx": "^2.1.1", + "d3-hierarchy": "^3.1.2", + "d3-sankey": "^0.12.3", + "d3-scale": "^4.0.2", + "marked": "^18.0.7", "mode-watcher": "^1.1.0" }, "devDependencies": { @@ -19870,17 +19533,24 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", - "@types/node": "^26", + "@types/d3-dsv": "^3.0.7", + "@types/d3-hierarchy": "^3.1.7", + "@types/d3-sankey": "^0.12.5", + "@types/d3-scale": "^4.0.9", + "@types/node": "^24", + "d3-dsv": "^3.0.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.19.0", "globals": "^17.6.0", + "lodash-es": "^4.18.1", "svelte": "^5.56.8", "svelte-check": "^4.7.4", "tailwindcss": "^4.3.0", "typescript": "^7.0.2", "typescript-eslint": "^8.65.0", - "vite": "^8.2.1" + "vite": "^8.2.1", + "yaml": "^2.9.0" } }, "packages/results/node_modules/@oxc-project/types": { @@ -19986,9 +19656,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20006,9 +19673,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -20026,9 +19690,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20046,9 +19707,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20066,9 +19724,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20086,9 +19741,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -20149,6 +19801,16 @@ "node": "^20.19.0 || >=22.12.0" } }, + "packages/results/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "packages/results/node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -20292,9 +19954,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -20316,9 +19975,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -20340,9 +19996,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -20364,9 +20017,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -20490,6 +20140,13 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "packages/results/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "packages/results/node_modules/vite": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", @@ -20693,9 +20350,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20713,9 +20367,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -20733,9 +20384,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20753,9 +20401,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20773,9 +20418,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -20793,9 +20435,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -20999,9 +20638,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -21023,9 +20659,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -21047,9 +20680,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -21071,9 +20701,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/packages/archive/index.json b/packages/archive/index.json index 01529a8..b89fc52 100644 --- a/packages/archive/index.json +++ b/packages/archive/index.json @@ -1,73 +1,73 @@ [ { "year": 2026, - "results": null, + "results": "https://survey.stackoverflow.co/2026", "data": null, "source": null }, { "year": 2025, - "results": "/2025", + "results": "https://survey.stackoverflow.co/2025", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2025/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2025" }, { "year": 2024, - "results": "/2024", + "results": "https://survey.stackoverflow.co/2024", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2024/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2024" }, { "year": 2023, - "results": "/2023", + "results": "https://survey.stackoverflow.co/2023", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2023/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2023" }, { "year": 2022, - "results": "/2022", + "results": "https://survey.stackoverflow.co/2022", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2022/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2022" }, { "year": 2021, - "results": "/2021", + "results": "https://survey.stackoverflow.co/2021", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2021/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2021" }, { "year": 2020, - "results": "/2020", + "results": "https://survey.stackoverflow.co/2020", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2020/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2020" }, { "year": 2019, - "results": "/2019", + "results": "https://survey.stackoverflow.co/2019", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2019/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2019" }, { "year": 2018, - "results": "/2018", + "results": "https://survey.stackoverflow.co/2018", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2018/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2018" }, { "year": 2017, - "results": "/2017", + "results": "https://survey.stackoverflow.co/2017", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2017/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2017" }, { "year": 2016, - "results": "/2016", + "results": "https://survey.stackoverflow.co/2016", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2016/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2016" }, { "year": 2015, - "results": "/2015", + "results": "https://survey.stackoverflow.co/2015", "data": "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/2015/results.csv", "source": "https://github.com/StackExchange/Survey/tree/main/packages/archive/2015" }, diff --git a/packages/results/.env.example b/packages/results/.env.example new file mode 100644 index 0000000..255cac6 --- /dev/null +++ b/packages/results/.env.example @@ -0,0 +1,3 @@ +# The content sheet, read by `npm run content`. No API key: the script uses the +# CSV export endpoint, which needs the sheet shared "anyone with the link". +GOOGLE_SHEETS_SHEETID="" diff --git a/packages/results/.gitignore b/packages/results/.gitignore index cd5ad2e..7028f28 100644 --- a/packages/results/.gitignore +++ b/packages/results/.gitignore @@ -25,3 +25,6 @@ vite.config.ts.timestamp-* # Remove once we have data /src/content /src/data + +# Generated by `npm run data -w results`, not checked in +/src/generated diff --git a/packages/results/README.md b/packages/results/README.md index cb66316..02e1d25 100644 --- a/packages/results/README.md +++ b/packages/results/README.md @@ -7,8 +7,61 @@ npm run dev -w results npm run build -w results ``` +## Live preview + +```bash +npm run dev -w results +``` + +## Sync content + +Content is stored in a public Google Sheet. To convert this to `src/survey,json` run this command. + +```bash +npm run content -w results +``` + +## Types of graphic + +### Expressive + +The 3D ("Hero") and 2D ("Highlight") types. + +| Concept | Hero (3D) | Highlight (2D) | +| --------------------------------------- | --------------------------- | --------------------------- | +| Magnitude comparison (2 values) | `3d-cube` | `2d-treemap-small` | +| % of whole (large) | `3d-waffle-large` | `2d-waffle-large` | +| % of whole ("1 in X") | `3d-waffle-medium` | `2d-waffle-medium` | +| Proportional areas (multi-category) | `3d-treemap` | `2d-treemap` | +| Categorical breakdown, vertical | `3d-bar-stacked-vertical` | `2d-bar-stacked-vertical` | +| Categorical breakdown, horizontal | `3d-bar-stacked-horizontal` | `2d-bar-stacked-horizontal` | +| Min/max comparison | `3d-bar-stacked-minmax` | `2d-bar-stacked-minmax` | +| Ranked Top X | `3d-rank` | `rank` | +| Single stat callout (etc 1/4, 7.5, 85%) | `hero-stat` | `stat` | +| Respondant quote | — | `quote` | + +### Standard + +Used in "Data" section of each chapter. + +Set per question in the `chart` column of the content sheet, and resolved through +the registry in `src/lib/charts/index.ts` — a value with no entry there draws +nothing, so the ids below are the whole list. + +| Type | uid | Row shape it draws | +| ------------- | --------------- | -------------------------------------------------------- | +| Bar | `bar` | one row per response | +| Bar stacked | `bar-stacked` | a row per (response, series); segments sum to 100% | +| Bar clustered | `bar-clustered` | a row per (response, series); one bar each, shared scale | +| Dumbbell | `dumbbell` | a row per (response, series), exactly two series | +| Sankey | `sankey` | a row per (source, target) — `response` → `series` | +| Scatter | `scatter` | one row per response, two named numeric columns | +| Table | `table` | any columns; headings come from `src/lib/labels.ts` | + ## Site structure +The results site has the following heirarchy. + ### Earlier years 2015–2025 are static HTML committed under `packages/archive`, moved into this packages by the build script: @@ -16,3 +69,23 @@ npm run build -w results ``` npm run archive -w results ``` + +### `/`: Developer Survey index + +This is an index to all the previous year’s results. The data comes from `/packages/archive/index.json`. + +### `/[year]`: This year’s survey + +An editorialised introduction to this year’s survey. Each chapter has ~2 hero infographics design to give an quick overview of the trends and insights. Uses the `Hero (3D)` column of graphics. + +## `/[years]/[chapter]`: Chapter overview + +~5 key data points from the chapter presented in a less expressive but still infographic approach. Uses the `Highlight (2D)` column of graphics. + +## `/[years]/[chapter]/data`: Complete chapter data + +The data broadly mapped to the exact question asked, including the option to filters by demographics. Uses the `Standard` list of charts. + +## `/[years]/[chapter]/data/[question]?p=[plot_id]`: Question permalink + +An individual question and it demographics filter via query string `p`. diff --git a/packages/results/src/lib/constants.ts b/packages/results/config.ts similarity index 63% rename from packages/results/src/lib/constants.ts rename to packages/results/config.ts index 6aaf095..6423ada 100644 --- a/packages/results/src/lib/constants.ts +++ b/packages/results/config.ts @@ -1,6 +1,5 @@ -// Non-editorial, slower moving global settings +// Everything about this site that isn't editorial copy or survey data. -// Absolute: during prerendering `page.url.origin` is `http://sveltekit-prerender`. export const siteUrl = 'https://survey.stackoverflow.co' export const siteName = 'Stack Overflow Developer Survey' @@ -12,12 +11,13 @@ export const siteDescription = 'Results of the annual Stack Overflow Developer S export const siteDescriptionLong = 'Every year we ask developers about the tools they use, how they learn, and how they work. Explore the results of the largest survey of people who code.' -// Baked in at build time: the site is prerendered, so `new Date()` would freeze -// on whichever day the build ran anyway. -export const copyrightYear = 2026 - export const ogImage = '/stack-overflow-dev-survey-open-graph.png' +export const githubRepo = 'https://github.com/StackExchange/Survey' + +// The instrument as respondents met it. `#q-` scrolls to one question. +export const surveyPreview = 'https://stackoverflow-survey-preview.netlify.app' + // Tailwind token fragments composed at call sites — `bg-${primary}`. Anything // new here needs adding to the `@source inline` safelist in routes/layout.css. const chapterColours = [ @@ -30,18 +30,6 @@ const chapterColours = [ export const chapterColour = (index: number) => chapterColours[(index - 1) % chapterColours.length] -export const githubRepo = 'https://github.com/StackExchange/Survey' - -// The instrument as respondents met it. `#q-` scrolls to one question. -export const surveyPreview = 'https://stackoverflow-survey-preview.netlify.app' - -export const promo = { - visible: false, - url: 'https://take.survey.stackoverflow.co/jfe/form/SV_4GHunpL3IfJ3rRc?utm_medium=referral&utm_source=survey-results&utm_campaign=dev-survey-2026&utm_content=announcement-banner-survey', - text: 'The 2026 Stack Overflow Developer Survey is live — take it now →', - dismissedKey: 'ds-promo-dismissed', -} - export const licence = { database: { name: 'ODbL 1.0', url: 'https://opendatacommons.org/licenses/odbl/1-0/' }, contents: { name: 'DbCL 1.0', url: 'https://opendatacommons.org/licenses/dbcl/1-0/' }, @@ -49,3 +37,35 @@ export const licence = { code: { name: 'Apache-2.0', url: `${githubRepo}/blob/main/LICENSE.md` }, holder: 'Stack Exchange Inc.', } + +// The publisher, as schema.org describes it. `sameAs` is how a consumer knows +// this Organization is the same one it has seen elsewhere. +export const organisation = { + name: 'Stack Overflow', + legalName: 'Stack Exchange Inc.', + url: 'https://stackoverflow.com/', + logo: '/apple-touch-icon.png', + sameAs: [ + 'https://stackexchange.com/', + 'https://www.linkedin.com/company/stack-overflow/', + 'https://github.com/StackExchange', + 'https://twitter.com/stackoverflow', + 'https://www.instagram.com/thestackoverflow/', + 'https://www.youtube.com/c/StackOverflowOfficial', + 'https://www.threads.net/@thestackoverflow', + ], +} + +// The rest of what the JSON-LD asserts about the survey as a dataset. +export const dataset = { + language: 'en', + measurementTechnique: 'Self-administered online questionnaire', + keywords: [ + 'software development', + 'developer survey', + 'programming languages', + 'artificial intelligence', + 'developer compensation', + 'remote work', + ], +} diff --git a/packages/results/eslint.config.js b/packages/results/eslint.config.js index ac6f1b8..bdc1e47 100644 --- a/packages/results/eslint.config.js +++ b/packages/results/eslint.config.js @@ -25,10 +25,10 @@ export default defineConfig([ }, { - // Override or add rule settings here // https://sveltejs.github.io/eslint-plugin-svelte/rules/ - // rules: { - // 'svelte/no-at-html-tags': 'off', - // }, + rules: { + 'svelte/no-at-html-tags': 'off', + 'svelte/no-navigation-without-resolve': 'off', + }, }, ]) diff --git a/packages/results/netlify.toml b/packages/results/netlify.toml new file mode 100644 index 0000000..1a6b5e4 --- /dev/null +++ b/packages/results/netlify.toml @@ -0,0 +1,113 @@ +[build] + base = "packages/results" + command = "npm run build" + publish = "build" + +[build.environment] + # Pin the toolchain so CI matches local rather than following Netlify's default. + NODE_VERSION = "24" + +# Security headers. Deliberately not a Content-Security-Policy: the two +# third-party tags in app.html would have to be allowlisted anyway, and OneTrust +# injects inline styles at runtime, so an accurate policy ends up permissive +# enough to buy little. Revisit with Content-Security-Policy-Report-Only if that +# changes. +[[headers]] + for = "/*" + + [headers.values] + # No `preload`: that commits the domain to the browser preload list and is + # slow to undo, so it belongs to a decision at the apex domain, not here. + Strict-Transport-Security = "max-age=31536000; includeSubDomains" + X-Content-Type-Options = "nosniff" + X-Frame-Options = "DENY" + Referrer-Policy = "strict-origin-when-cross-origin" + Permissions-Policy = "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + +# Content-hashed filenames, so they can be cached indefinitely. Without this +# Netlify serves them `must-revalidate`, costing a 304 per asset per visit. +[[headers]] + for = "/_app/immutable/*" + + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +# Chart filenames are stable so that shared and embedded URLs keep working, which +# rules out `immutable`. A day at the edge with revalidation after that is the +# trade: manifest.json carries a hash per file for anything that needs to know +# precisely what changed. Filenames carry the year, so all years share this +# directory without colliding. +[[headers]] + for = "/charts/*" + + [headers.values] + Cache-Control = "public, max-age=86400, stale-while-revalidate=604800" + +# Every page has a .md twin, linked from as rel="alternate". Declared +# rather than left to Netlify's extension guess: X-Content-Type-Options is +# nosniff above, so a wrong guess here is a wrong type at the client with no +# recovery. Browsers download rather than render text/markdown; the audience for +# these is models, and the HTML twin is what a person follows. +[[headers]] + for = "/*.md" + + [headers.values] + Content-Type = "text/markdown; charset=utf-8" + +# --- Earlier years ----------------------------------------------------------- +# +# This site owns the apex; 2011–2025 are served by packages/archive and proxied +# through. Everything goes to the archive, including 2025 — that year's results +# site is a third deploy, but the archive is what holds its data files and what +# runs the survey-banner edge function, so it has to stay in the path. It +# forwards to the 2025 deploy itself. +# +# No `200!` needed: forcing exists to beat a local file, and this site publishes +# nothing under a past year's path. +# +# Data files bounce to GitHub rather than being served from here. They run to +# ~159MB apiece and have attracted abuse, and a 200-proxy would bill that +# bandwidth to this site and the archive both. The URLs stay valid and citable — +# they just cost a redirect instead of the file. +# +# 302, not 301: a permanent redirect is cached by browsers indefinitely and this +# is likely to move again. Must precede the generic /:year/* rule below. +[[redirects]] + from = "/:year/results.csv" + to = "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/:year/results.csv" + status = 302 + +[[redirects]] + from = "/:year/schema.csv" + to = "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/:year/schema.csv" + status = 302 + +[[redirects]] + from = "/:year/survey.pdf" + to = "https://github.com/StackExchange/Survey/raw/refs/heads/main/packages/archive/:year/survey.pdf" + status = 302 + +# A bare /YYYY needs its own rule — /YYYY/* does not match it. The archive's own +# _redirects turn these into /YYYY/site/… on its side, so the paths handed over +# are the public ones. +[[redirects]] + from = "/:year" + to = "https://stackoverflow-survey.netlify.app/:year" + status = 200 + +[[redirects]] + from = "/:year/*" + to = "https://stackoverflow-survey.netlify.app/:year/:splat" + status = 200 + +# Root-absolute in the 2015–2021 pages, so it cannot move off the apex. +[[redirects]] + from = "/legacy-assets/*" + to = "https://stackoverflow-survey.netlify.app/legacy-assets/:splat" + status = 200 + +# Legacy shortcut, carried over from the archive's _redirects. +[[redirects]] + from = "/survey/*" + to = "https://survey.stackoverflow.co/:splat" + status = 302 diff --git a/packages/results/package.json b/packages/results/package.json index d65a4a9..163efd3 100644 --- a/packages/results/package.json +++ b/packages/results/package.json @@ -12,7 +12,9 @@ "prepare": "svelte-kit sync || echo ''", "check": "svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "eslint ." + "lint": "eslint .", + "data": "node scripts/data.js", + "content": "node --env-file-if-exists=../../.env scripts/gsheet.js" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -22,21 +24,32 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.3.0", - "@types/node": "^26", + "@types/d3-dsv": "^3.0.7", + "@types/d3-hierarchy": "^3.1.7", + "@types/d3-sankey": "^0.12.5", + "@types/d3-scale": "^4.0.9", + "@types/node": "^24", + "d3-dsv": "^3.0.1", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.19.0", "globals": "^17.6.0", + "lodash-es": "^4.18.1", "svelte": "^5.56.8", "svelte-check": "^4.7.4", "tailwindcss": "^4.3.0", "typescript": "^7.0.2", "typescript-eslint": "^8.65.0", - "vite": "^8.2.1" + "vite": "^8.2.1", + "yaml": "^2.9.0" }, "dependencies": { "@stackoverflow/stacks-icons": "^7.0.0-beta.28", "clsx": "^2.1.1", + "d3-hierarchy": "^3.1.2", + "d3-sankey": "^0.12.3", + "d3-scale": "^4.0.2", + "marked": "^18.0.7", "mode-watcher": "^1.1.0" } } diff --git a/packages/results/scripts/data.js b/packages/results/scripts/data.js new file mode 100644 index 0000000..72126f7 --- /dev/null +++ b/packages/results/scripts/data.js @@ -0,0 +1,367 @@ +// Bakes the content sheet, the survey export and the question bank into one file +// per route payload under src/generated. A route's `load` then returns its file. +// +// Runs from `npm run data`, and from the Vite plugin in vite.config.js on every +// dev start and build — so it can't be forgotten. +// +// One file on purpose: it is a single pass with no branching, and reading it top +// to bottom is the documentation for what the payloads contain. + +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { kebabCase } from 'lodash-es' +import { marked } from 'marked' +import YAML from 'yaml' + +import { graphsFor } from '../src/lib/jsonld.ts' +import { STRUCTURAL, labelFor } from '../src/lib/labels.ts' + +marked.use({ breaks: true, gfm: true }) + +function html(md) { + if (typeof md !== 'string' || md === '') return '' + + const inline = !md.includes('\n') && !/^\s*[-*]\s/m.test(md) + return String(inline ? marked.parseInline(md) : marked.parse(md)).trim() +} + +const APP = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const REPO = path.resolve(APP, '../..') +const OUT = path.join(APP, 'src/generated') + +const readJson = async (file) => JSON.parse(await fs.readFile(file, 'utf8')) + +// Earlier years, for the DataCatalog on the home page. +const readYears = () => readJson(path.resolve(APP, '../archive/index.json')) + +// --- inputs ------------------------------------------------------------------ + +async function readInputs() { + const dataDir = path.join(APP, 'src/data') + const files = (await fs.readdir(dataDir)).filter((f) => f.endsWith('.json')) + + const data = Object.fromEntries( + await Promise.all(files.map(async (f) => [f.replace(/\.json$/, ''), await readJson(path.join(dataDir, f))])) + ) + + // The question bank, keyed by id, with the repo-relative path a page links to. + const bank = {} + const groups = await fs.readdir(path.join(REPO, 'questions'), { withFileTypes: true }) + + for (const group of groups.filter((e) => e.isDirectory())) { + for (const file of (await fs.readdir(path.join(REPO, 'questions', group.name))).filter((f) => f.endsWith('.yaml'))) { + const source = `questions/${group.name}/${file}` + const doc = YAML.parse(await fs.readFile(path.join(REPO, 'questions', group.name, file), 'utf8')) + if (doc?.question?.id) bank[doc.question.id] = { ...doc.question, titleHtml: html(doc.question.title), source } + } + } + + const survey = await readJson(path.join(APP, 'src/content/survey.json')) + + // The scalars the methodology page prints, carried in the payload so no module + // outside this file imports from $data. + const methodology = Object.fromEntries(Object.entries(data.methodology ?? {}).filter(([, v]) => typeof v !== 'object')) + + return { survey, data, bank, methodology } +} + +// --- figures ----------------------------------------------------------------- + +// A bare reference, for the places that only link to a chapter: the prev/next +// cards and the chapter a question belongs to. Carrying the full summary there +// shipped a lorem paragraph and its HTML into all 28 question payloads. +const ref = ({ id, name, index }) => ({ id, name, index }) + +// The full header copy, for the page that draws the chapter itself. +const summary = ({ id, name, index, description, descriptionLong }) => ({ + id, + name, + index, + description, + descriptionHtml: html(description), + descriptionLong, + descriptionLongHtml: html(descriptionLong), +}) + +// A chapter is drawable once its export has been regenerated. The legacy shape +// nests cuts under `datasets`; the tidy one declares them up front. +const isTidy = (question) => Array.isArray(question?.meta?.slices) + +const valueKeys = (rows) => [...new Set(rows.flatMap((row) => Object.keys(row).filter((key) => !STRUCTURAL.has(key))))] + +// A bar draws the share. Where the rows carry none — the salary questions — it +// draws the first named column instead. +const valueFor = (rows) => { + if (rows.some((row) => typeof row.pct === 'number')) return null + const [first] = valueKeys(rows) + return first ? labelFor(first) : null +} + +// A scatter needs two, and which is which the data can't say. The convention: +// the explanatory column on x, the money on y. Right for the only scatter in the +// survey, and wrong the day a currency column stops ending in `_usd` — so the run +// prints what it resolved. +const axesFor = (rows) => { + const keys = valueKeys(rows) + const x = keys.find((key) => !key.endsWith('_usd')) + const y = keys.find((key) => key.endsWith('_usd')) + return x && y ? { x: labelFor(x), y: labelFor(y) } : null +} + +// One cut: its rows, minus the slice index that selected them, minus any column +// null in all of them. Past this point a row stands alone. +function groupOf(question, slice, at, completions) { + const rows = question.data.filter((row) => row.slice === at) + const used = new Set() + for (const row of rows) for (const [key, value] of Object.entries(row)) if (key !== 'slice' && value !== null) used.add(key) + + const series = [...new Set(rows.map((row) => row.series).filter((v) => typeof v === 'string'))] + + return { + demographic: { + id: kebabCase(slice.slice_value), + type: slice.slice_type ?? null, + name: slice.slice_value, + n: slice.n ?? null, + share: typeof slice.n === 'number' && completions ? slice.n / completions : null, + }, + // Segment order is emission order: the export writes response-major, so the + // first response sets the stack order for every response after it. + series: series.length ? series : null, + data: rows.map((row) => Object.fromEntries(Object.entries(row).filter(([key]) => used.has(key)))), + } +} + +// Everything a figure derives from the data, with no sheet copy attached — a +// question adds its Questions row, a promoted figure its Features row. +function resolve(ctx, chapterId, dataId, where, chart) { + const question = ctx.data[chapterId]?.[dataId] + if (!question) return ctx.fail(`${where}: no question "${dataId}" in ${chapterId}.json`) + // A chapter is skipped wholesale when nothing in it is tidy, so reaching here + // with a legacy question means the file is half-migrated. + if (!isTidy(question)) return ctx.fail(`${where}: "${dataId}" is still the legacy format`) + + const groups = question.meta.slices.map((slice, at) => groupOf(question, slice, at, ctx.completions)) + if (!groups.length) return ctx.fail(`${where}: "${dataId}" declares no slices`) + + const definition = ctx.bank[question.meta?.qname] ?? null + const rows = groups[0].data + const axes = chart === 'scatter' ? axesFor(rows) : null + + if (chart === 'scatter' && !axes) { + return ctx.fail(`${where}: a scatter needs one non-currency and one currency column, found ${valueKeys(rows).join(', ')}`) + } + + // `response -> short` for the strings this figure draws, from the question bank. + const asked = Object.fromEntries((definition?.options ?? []).filter((o) => o?.short).map((o) => [o.label, o.short])) + const shorts = {} + for (const row of question.data) for (const v of [row.response, row.series]) if (asked[v]) shorts[v] = asked[v] + + return { dataId, question: question.meta?.question ?? null, definition, value: valueFor(rows), axes, shorts, groups } +} + +// A question with every cut. The cuts are the only copy of the rows — spreading +// the first one flat as well would write every row twice, and both pages already +// fall back to `demographics[0]`. +function figureOf(ctx, chapterId, q) { + const { groups, ...resolved } = resolve(ctx, chapterId, q.dataId, `question "${q.name}" (${chapterId})`, q.chart) ?? {} + if (!groups) return null + + return { + kind: 'figure', + ...q, + id: q.dataIdSlug, + descriptionHtml: html(q.description), + ...resolved, + subtext: q.subtext || null, + demographics: groups, + } +} + +// A figure the sheet promoted, narrowed to the one cut and the rows it asked for. +function featureOf(ctx, chapter, ref) { + if (ref.chart === 'quote') + return { kind: 'quote', chart: 'quote', headline: ref.headline, description: ref.description, descriptionHtml: html(ref.description) } + + const where = `${ref.tier} "${ref.headline}" (${chapter.id})` + const { groups, ...resolved } = resolve(ctx, chapter.id, ref.dataId, where, ref.chart) ?? {} + if (!groups) return null + + const wanted = ref.dataset?.[0] + const group = wanted ? groups.find((g) => g.demographic.id === kebabCase(wanted) || g.demographic.name === wanted) : groups[0] + if (!group) return ctx.fail(`${where}: no cut "${wanted}" — has ${groups.map((g) => g.demographic.name).join(', ')}`) + + let data = group.data + + // Responses get reworded between years, and the figure would render empty. + const missing = (ref.values ?? []).filter((v) => !data.some((r) => r.response === v)) + if (missing.length) return ctx.fail(`${where}: no row ${missing.join(', ')} — rows are ${data.map((r) => r.response).join(', ')}`) + + if (ref.values?.length) data = ref.values.map((v) => data.find((r) => r.response === v)) + if (ref.limit) data = data.slice(0, Number(ref.limit)) + + // The section and slug the question sits at, for the "in context" link. + const section = (chapter.sections ?? []).find((s) => (s.questions ?? []).some((q) => q.dataId === ref.dataId)) + + return { + kind: 'figure', + ...resolved, + ...group, + chart: ref.chart, + headline: ref.headline, + description: ref.description, + descriptionHtml: html(ref.description), + subtext: ref.subtext || null, + section: section?.name ?? null, + slug: (section?.questions ?? []).find((q) => q.dataId === ref.dataId)?.dataIdSlug ?? null, + data, + } +} + +const featuresOf = (ctx, chapter, tier) => + (chapter.features ?? []) + .filter((f) => f.tier === tier) + .map((f) => featureOf(ctx, chapter, f)) + .filter(Boolean) + +// --- assembly ---------------------------------------------------------------- + +// Only write when the bytes changed: the generator writes inside Vite's watched +// root, so an unconditional write is a reload, which is another run. +async function write(file, value, state) { + const target = path.join(OUT, file) + const body = `${JSON.stringify(value, null, '\t')}\n` + const existing = await fs.readFile(target, 'utf8').catch(() => null) + + state.bytes += Buffer.byteLength(body) + if (existing === body) return + + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, body) + + state.changed++ + if (existing === null) state.structural = true +} + +export async function generate() { + const { survey, data, bank, methodology } = await readInputs() + const years = await readYears() + + // Collected, not thrown one at a time: whoever has to fix the sheet or rerun + // the pipeline should get the whole list in one pass. + const problems = [] + const ctx = { data, bank, completions: methodology.TotalRespondents, fail: (message) => void problems.push(message) } + + // A chapter whose export hasn't been regenerated doesn't exist yet, so it is + // skipped rather than failed — no route, no nav, no sitemap entry. + const live = survey.chapters.filter((chapter) => { + const file = data[chapter.id] + + if (!file) return void console.error(`⚠ chapter "${chapter.id}" skipped — no src/data/${chapter.id}.json`) + if (!Object.values(file).some(isTidy)) return void console.error(`⚠ chapter "${chapter.id}" skipped — still the legacy format`) + + return true + }) + + for (const id of Object.keys(data)) { + if (id !== 'methodology' && !survey.chapters.some((c) => c.id === id)) + console.error(`⚠ src/data/${id}.json is not claimed by any chapter`) + } + + const summaries = live.map(summary) + const wrap = (i) => summaries[(i + summaries.length) % summaries.length] + const nextTo = (id) => { + const at = summaries.findIndex((c) => c.id === id) + return { previous: ref(wrap(at - 1)), next: ref(wrap(at + 1)) } + } + + const state = { changed: 0, bytes: 0, structural: false } + const index = {} + const axes = [] + + // Built in memory first: the schema.org graphs need every chapter's questions + // resolved before any payload can be written. + const chapterPayloads = {} + const questionPayloads = {} + + for (const chapter of live) { + // Questions flattened out of their sections, so neighbours are findable. + const flat = (chapter.sections ?? []).flatMap((section) => + (section.questions ?? []).map((q) => ({ ...q, sectionId: section.id, sectionName: section.name })) + ) + + const drawable = flat.map((q) => figureOf(ctx, chapter.id, q)).filter(Boolean) + const sibling = (i) => (drawable[i] ? { slug: drawable[i].dataIdSlug, name: drawable[i].name } : null) + + index[chapter.id] = drawable.map((f) => ({ slug: f.dataIdSlug, name: f.name })) + for (const f of drawable) if (f.axes) axes.push(`${f.dataId}: x=${f.axes.x.key} y=${f.axes.y.key}`) + + chapterPayloads[chapter.id] = { + ...summary(chapter), + sections: (chapter.sections ?? []).map((section) => ({ + ...section, + questions: (section.questions ?? []).map((q) => drawable.find((f) => f.dataId === q.dataId)).filter(Boolean), + })), + ...nextTo(chapter.id), + } + + for (const [at, figure] of drawable.entries()) { + questionPayloads[`${chapter.id}/${figure.dataIdSlug}`] = { + question: { ...figure, previous: sibling(at - 1), next: sibling(at + 1) }, + chapter: ref(chapter), + } + } + } + + const seo = graphsFor({ survey, years, chapters: summaries, chapterPayloads }) + + for (const chapter of live) { + await write( + `chapter/${chapter.id}.json`, + { ...summary(chapter), highlights: featuresOf(ctx, chapter, 'highlight'), ...nextTo(chapter.id), jsonld: seo.chapter[chapter.id] }, + state + ) + + await write(`data/${chapter.id}.json`, { ...chapterPayloads[chapter.id], jsonld: seo.dataPage[chapter.id] }, state) + } + + for (const [key, payload] of Object.entries(questionPayloads)) { + await write(`question/${key}.json`, { ...payload, jsonld: seo.question[key] }, state) + } + + await write('year.json', { chapters: live.map((c) => ({ ...summary(c), heroes: featuresOf(ctx, c, 'hero') })), jsonld: seo.year }, state) + + const year = String(survey.settings.year) + + await write( + 'site.json', + { + settings: survey.settings, + methodology, + jsonld: { home: seo.home, methodology: seo.methodology }, + chapters: summaries, + questions: index, + // Which routes to prerender, so no route recomputes the list. + entries: { + year: [{ year }], + chapter: summaries.map(({ id }) => ({ year, chapter: id })), + question: summaries.flatMap(({ id }) => index[id].map(({ slug }) => ({ year, chapter: id, question: slug }))), + }, + }, + state + ) + + if (problems.length) throw new Error(`data: ${problems.length} problem(s)\n\n ${problems.join('\n ')}\n`) + + for (const line of axes) console.error(`· scatter ${line}`) + + return { ...state, chapters: live.length, questions: Object.values(index).flat().length } +} + +export const summarise = (r) => + `✅ ${r.chapters} chapters, ${r.questions} questions → src/generated (${r.changed} written, ${Math.round(r.bytes / 1024)} KB)` + +// Run directly, rather than imported by the Vite plugin. +if (process.argv[1] === fileURLToPath(import.meta.url)) console.error(summarise(await generate())) diff --git a/packages/results/scripts/gsheet.js b/packages/results/scripts/gsheet.js new file mode 100644 index 0000000..32315f6 --- /dev/null +++ b/packages/results/scripts/gsheet.js @@ -0,0 +1,99 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { csvParseRows } from 'd3-dsv' +import { camelCase, kebabCase } from 'lodash-es' + +// Where to save this +const OUT = path.resolve(fileURLToPath(import.meta.url), '../../src/content/survey.json') + +// The Copy Spreadsheet - MUST BE SET TO PUBLIC +// https://docs.google.com/spreadsheets/d/[ID FROM HERE]/edit# +const SHEET_ID = process.env.GOOGLE_SHEETS_SHEETID + +// Columns with split with `|` +const LISTS = new Set(['dataset', 'values']) + +if (!SHEET_ID) throw new Error('GOOGLE_SHEETS_SHEETID is not set — see .env.example') + +// Define which tabs we accept +const [settings, chapters, sections, questions, features] = await Promise.all( + ['Settings', 'Chapters', 'Sections', 'Questions', 'Features'].map(getSheet) +) + +function mapRow(headers, row) { + const out = {} + + headers.forEach((header, i) => { + const key = camelCase(header) + const raw = (row[i] ?? '').trim() + + if (!key) return + else if (raw === 'TRUE' || raw === 'FALSE') out[key] = raw === 'TRUE' + else if (LISTS.has(key)) out[key] = raw ? raw.split('|').map((v) => v.trim()) : [] + else out[key] = raw + }) + + // Kebab the name so it can be an html id, and pre-compute the dataId slug so components never have to + if (out.name) out.id = kebabCase(out.name) + if (out.dataId) out.dataIdSlug = kebabCase(out.dataId) + + return out +} + +async function getSheet(name) { + const res = await fetch(`https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(name)}`) + + // A private sheet 401s, but a missing tab answers 200 with an HTML error page + if (!res.ok) throw new Error(`${name}: HTTP ${res.status} — is the sheet shared with "anyone with the link"?`) + if (!res.headers.get('content-type')?.includes('csv')) throw new Error(`${name}: not CSV — does that tab exist?`) + + // d3-dsv is ragged-tolerant, so Sheets' padded grid needs no options. It does + // not strip a BOM though, and one would ride into the first header name and + // camelCase into a key nothing reads — losing that column silently. + const [headers, ...rows] = csvParseRows((await res.text()).replace(/^\uFEFF/, '')) + + // Sheets pads the grid, so a blank row is all-empty rather than absent. + return rows.filter((row) => row.some((field) => field !== '')).map((row) => mapRow(headers, row)) +} + +const byName = new Map(chapters.map((chapter) => [chapter.name, chapter])) +const dropped = [] + +for (const section of sections) { + const chapter = byName.get(section.chapter) + if (chapter) (chapter.sections ??= []).push(section) + else dropped.push(`section "${section.name}" — no chapter "${section.chapter}"`) +} + +for (const question of questions) { + const section = byName.get(question.chapter)?.sections?.find((s) => s.name === question.section) + if (section) (section.questions ??= []).push(question) + else dropped.push(`question "${question.name}" — no section "${question.chapter} / ${question.section}"`) +} + +for (const feature of features) { + const chapter = byName.get(feature.chapter) + if (chapter) (chapter.features ??= []).push(feature) + else dropped.push(`${feature.tier} "${feature.headline}" — no chapter "${feature.chapter}"`) +} + +// Position is the chapter's own, so it belongs with the chapter rather than being +// recomputed by every consumer. 1-based, for the section numbering on data pages. +chapters.forEach((chapter, i) => (chapter.index = i + 1)) + +// The `settings` tab is a Name/Value store rather than a table, so it misses the +// TRUE/FALSE coercion in mapRow — do it here so flags read as booleans. +const survey = { + settings: Object.fromEntries(settings.map((r) => [camelCase(r.name), r.value === 'TRUE' ? true : r.value === 'FALSE' ? false : r.value])), + chapters, +} + +await fs.writeFile(OUT, `${JSON.stringify(survey, null, 2)}\n`) + +for (const row of dropped) console.error(`✕ dropped ${row}`) + +console.error( + `✅ ${chapters.length} chapters, ${sections.length} sections, ${questions.length} questions, ${features.length} features → src/content/survey.json` +) diff --git a/packages/results/src/app.d.ts b/packages/results/src/app.d.ts index 67c5913..ea67eaf 100644 --- a/packages/results/src/app.d.ts +++ b/packages/results/src/app.d.ts @@ -1,6 +1,11 @@ // See https://svelte.dev/docs/kit/types#app.d.ts declare global { - namespace App {} + namespace App { + interface PageState { + /** Set by `openQuestion` in $lib/panel — the question route's data, for QuestionPanel. */ + question?: import('./routes/[year]/[chapter]/data/[question]/$types').PageData + } + } } export {} diff --git a/packages/results/src/lib/charts/ChartDownload.svelte b/packages/results/src/lib/charts/ChartDownload.svelte new file mode 100644 index 0000000..e245c3a --- /dev/null +++ b/packages/results/src/lib/charts/ChartDownload.svelte @@ -0,0 +1,135 @@ + + +
+ +
{@render chart({ block: selection.shown, chrome, width: CHART_WIDTH })}
+ +
+ Download this chart + + {#if scalable} + + {/if} + + + + +
+ + {#if selection.listable && selection.touched} +

+ Drawing {selection.kept.length} of {selection.rows.length} rows{#if selection.focus.length}, {selection.focus.length} highlighted{/if}. + +

+ {/if} +
+ +

{status === 'idle' ? '' : label}

diff --git a/packages/results/src/lib/charts/ChartRoot.svelte b/packages/results/src/lib/charts/ChartRoot.svelte new file mode 100644 index 0000000..28427c6 --- /dev/null +++ b/packages/results/src/lib/charts/ChartRoot.svelte @@ -0,0 +1,20 @@ + + + diff --git a/packages/results/src/lib/charts/Hero.svelte b/packages/results/src/lib/charts/Hero.svelte new file mode 100644 index 0000000..4749163 --- /dev/null +++ b/packages/results/src/lib/charts/Hero.svelte @@ -0,0 +1,61 @@ + + +
+
+ + {#if children} + {@render children()} + {:else} +
+

{block.headline}

+ {#if block.description} +
{@html block.descriptionHtml}
+ {/if} +
+ + question && openQuestion(event, question)} + > + Dig deeper + + + {/if} +
+
+ +
+ +
+
diff --git a/packages/results/src/lib/charts/Highlight.svelte b/packages/results/src/lib/charts/Highlight.svelte new file mode 100644 index 0000000..fdb2662 --- /dev/null +++ b/packages/results/src/lib/charts/Highlight.svelte @@ -0,0 +1,36 @@ + + +
+
+ {#if children} + {@render children()} + {:else} +

{block.headline}

+ {#if block.description} +
{@html block.descriptionHtml}
+ {/if} + {/if} +
+ +
+ +
+
diff --git a/packages/results/src/lib/charts/Tooltip.svelte b/packages/results/src/lib/charts/Tooltip.svelte new file mode 100644 index 0000000..9bbb290 --- /dev/null +++ b/packages/results/src/lib/charts/Tooltip.svelte @@ -0,0 +1,47 @@ + + + + +{#if data && event} + +{/if} diff --git a/packages/results/src/lib/charts/hero/BarStackedHorizontal.svelte b/packages/results/src/lib/charts/hero/BarStackedHorizontal.svelte new file mode 100644 index 0000000..7040b4f --- /dev/null +++ b/packages/results/src/lib/charts/hero/BarStackedHorizontal.svelte @@ -0,0 +1,86 @@ + + + + {#each rows as row, i (row.response ?? i)} + {@const length = px(x(amount(row)))} + {@const y = px(LEAD + i * (BAR + GAP))} + {@const box = slab(0, y, length, BAR, depth)} + + + + + + + + + + {clip(short(row.response), chars(width - 90, LABEL_SIZE))} + + + {format(row)} + + + enter(i, row, event)} + onpointerleave={leave} + onpointercancel={leave} + /> + {/each} + diff --git a/packages/results/src/lib/charts/hero/BarStackedMinmax.svelte b/packages/results/src/lib/charts/hero/BarStackedMinmax.svelte new file mode 100644 index 0000000..09a9abf --- /dev/null +++ b/packages/results/src/lib/charts/hero/BarStackedMinmax.svelte @@ -0,0 +1,82 @@ + + + + + {#each [...ends].reverse() as row, r (row.response ?? r)} + {@const i = ends.length - 1 - r} + {@const body = px(y(amount(row)))} + {@const floor = px(plot + depth * 0.5)} + {@const x = px(i * (column + gap))} + {@const box = slab(x, floor - body, column, body, depth)} + + + + + + + + {format(row)} + + {clip(short(row.response), chars(column + gap, LABEL_SIZE))} + + + enter(i, row, event)} + onpointerleave={leave} + onpointercancel={leave} + /> + {/each} + diff --git a/packages/results/src/lib/charts/hero/BarStackedVertical.svelte b/packages/results/src/lib/charts/hero/BarStackedVertical.svelte new file mode 100644 index 0000000..2640539 --- /dev/null +++ b/packages/results/src/lib/charts/hero/BarStackedVertical.svelte @@ -0,0 +1,82 @@ + + + + + {#each stack as row, i (row.response ?? i)} + {@const w = footprint(row)} + {@const y = px(height - (i + 1) * thickness)} + {@const x = px((base - w) / 2)} + {@const box = slab(x, y, w, thickness, depth * (w / base))} + + + + + + + + {format(row)} + + + + enter(i, row, event)} + onpointerleave={leave} + onpointercancel={leave} + /> + {/each} + diff --git a/packages/results/src/lib/charts/hero/Cube.svelte b/packages/results/src/lib/charts/hero/Cube.svelte new file mode 100644 index 0000000..f1c289c --- /dev/null +++ b/packages/results/src/lib/charts/hero/Cube.svelte @@ -0,0 +1,55 @@ + + + + + {#each rows as row, i (row.response ?? i)} + {@const s = side(row)} + {@const x = i === 0 ? 0 : px(side(rows[0]) + gap)} + + enter(row, i, event)} + onpointerleave={() => onhover?.(null)} + > + + + + + + {format(row)} + + {clip(short(row.response), chars(Math.max(s, 160), LABEL_SIZE))} + + {/each} + diff --git a/packages/results/src/lib/charts/hero/Rank.svelte b/packages/results/src/lib/charts/hero/Rank.svelte new file mode 100644 index 0000000..a17a011 --- /dev/null +++ b/packages/results/src/lib/charts/hero/Rank.svelte @@ -0,0 +1,91 @@ + + + + {#each rows as row, i (row.response ?? i)} + {@const body = px(y(amount(row)))} + {@const floor = px(plot + depth * SKEW + rise * i)} + {@const x = px(i * stride)} + {@const box = slab(x, floor - body, thickness, body, depth)} + {@const label = clip(short(row.response), chars(body - 24, LABEL_SIZE))} + + + + + + + + + {label} + + + + {format(row)} + + + + enter(i, row, event)} + onpointerleave={leave} + onpointercancel={leave} + /> + {/each} + diff --git a/packages/results/src/lib/charts/hero/Stat.svelte b/packages/results/src/lib/charts/hero/Stat.svelte new file mode 100644 index 0000000..c69b882 --- /dev/null +++ b/packages/results/src/lib/charts/hero/Stat.svelte @@ -0,0 +1,44 @@ + + + + + + + + + {figures}{unit} + + + {#if row} + + {clip(short(row.response), chars(width, LABEL_SIZE))} + + {/if} + diff --git a/packages/results/src/lib/charts/hero/Treemap.svelte b/packages/results/src/lib/charts/hero/Treemap.svelte new file mode 100644 index 0000000..90ed1a9 --- /dev/null +++ b/packages/results/src/lib/charts/hero/Treemap.svelte @@ -0,0 +1,60 @@ + + + + {#each rows as row, i (row.response ?? i)} + {@const size = sides[i] * unit} + {@const x = px(offsets[i] * unit)} + + enter(row, i, event)} + onpointerleave={() => onhover?.(null)} + > + + + + + + {format(row)} + + {clip(short(row.response), chars(Math.max(size, 90), LABEL_SIZE))} + + {/each} + diff --git a/packages/results/src/lib/charts/hero/WaffleLarge.svelte b/packages/results/src/lib/charts/hero/WaffleLarge.svelte new file mode 100644 index 0000000..d1b9f8d --- /dev/null +++ b/packages/results/src/lib/charts/hero/WaffleLarge.svelte @@ -0,0 +1,54 @@ + + + + {#each order as cell (cell.i)} + {@const point = at(cell.column, cell.line)} + {@const on = cell.i < filled} + + onhover?.(null)}> + + + + + {/each} + diff --git a/packages/results/src/lib/charts/hero/WaffleMedium.svelte b/packages/results/src/lib/charts/hero/WaffleMedium.svelte new file mode 100644 index 0000000..84f0320 --- /dev/null +++ b/packages/results/src/lib/charts/hero/WaffleMedium.svelte @@ -0,0 +1,51 @@ + + + + {#each order as cell (cell.i)} + {@const on = cell.i === 0} + + onhover?.(null)} + > + + + + + {/each} + diff --git a/packages/results/src/lib/charts/highlight/BarStackedHorizontal.svelte b/packages/results/src/lib/charts/highlight/BarStackedHorizontal.svelte new file mode 100644 index 0000000..4a84c4b --- /dev/null +++ b/packages/results/src/lib/charts/highlight/BarStackedHorizontal.svelte @@ -0,0 +1,86 @@ + + + + {#each rows as row, i (row.response ?? i)} + {@const y = i * (BAR + GAP)} + {@const length = px(x(amount(row)))} + {@const start = px((width - length) / 2)} + {@const split = px(length * Math.min(Math.max(amount(row), 0), 1))} + + {#if active === i} + + {/if} + + + + + + {#if start > 90} + + {clip(short(row.response), chars(start - 16, LABEL_SIZE))} + + {/if} + + enter(i, row, event)} + onpointerleave={leave} + onpointercancel={leave} + /> + {/each} + diff --git a/packages/results/src/lib/charts/highlight/BarStackedMinmax.svelte b/packages/results/src/lib/charts/highlight/BarStackedMinmax.svelte new file mode 100644 index 0000000..e9163fe --- /dev/null +++ b/packages/results/src/lib/charts/highlight/BarStackedMinmax.svelte @@ -0,0 +1,84 @@ + + + + {#each ends as row, i (row.response ?? i)} + {@const x = px(i * (column + gap))} + {@const body = px(y(amount(row)))} + {@const top = px(plot - body)} + + + + + + {format(row)} + + + {clip(short(row.response), chars(column + gap, LABEL_SIZE))} + + + enter(i, row, event)} + onpointerleave={leave} + onpointercancel={leave} + /> + {/each} + diff --git a/packages/results/src/lib/charts/highlight/BarStackedVertical.svelte b/packages/results/src/lib/charts/highlight/BarStackedVertical.svelte new file mode 100644 index 0000000..a5ed4f3 --- /dev/null +++ b/packages/results/src/lib/charts/highlight/BarStackedVertical.svelte @@ -0,0 +1,82 @@ + + + + {#each rows as row, i (row.response ?? i)} + {@const x = px(i * (column + GAP))} + {@const body = px(y(amount(row)))} + + {@const top = px(plot - body)} + + + + + + {format(row)} + + + {clip(short(row.response), chars(column, LABEL_SIZE))} + + + + enter(i, row, event)} + onpointerleave={leave} + onpointercancel={leave} + /> + {/each} + diff --git a/packages/results/src/lib/charts/highlight/Rank.svelte b/packages/results/src/lib/charts/highlight/Rank.svelte new file mode 100644 index 0000000..0c3312d --- /dev/null +++ b/packages/results/src/lib/charts/highlight/Rank.svelte @@ -0,0 +1,67 @@ + + + + {#each rows as row, i (row.response ?? i)} + {@const y = i * (ROW + GAP)} + + + + + + {i + 1}. {clip(short(row.response), chars(labelWidth, LABEL_SIZE))} + + + + {format(row)} + + + enter(i, row, event)} + onpointerleave={leave} + onpointercancel={leave} + /> + {/each} + diff --git a/packages/results/src/lib/charts/highlight/Treemap.svelte b/packages/results/src/lib/charts/highlight/Treemap.svelte new file mode 100644 index 0000000..6516367 --- /dev/null +++ b/packages/results/src/lib/charts/highlight/Treemap.svelte @@ -0,0 +1,47 @@ + + + + + {#each cells as cell, rank (cell.i)} + enter(cell, rank, event)} onpointerleave={() => onhover?.(null)}> + + + + {#if cell.height > LABEL_SIZE * 3 && cell.width > 70} + + {clip(short(cell.row.response), chars(cell.width - 20, LABEL_SIZE))} + + + {format(cell.row)} + + {/if} + + {/each} + diff --git a/packages/results/src/lib/charts/highlight/TreemapSmall.svelte b/packages/results/src/lib/charts/highlight/TreemapSmall.svelte new file mode 100644 index 0000000..dfa70a0 --- /dev/null +++ b/packages/results/src/lib/charts/highlight/TreemapSmall.svelte @@ -0,0 +1,46 @@ + + + + + {#each rows as row, i (row.response ?? i)} + {@const s = side(row)} + {@const x = i === 0 ? 0 : px(side(rows[0]) + GAP)} + + enter(row, i, event)} onpointerleave={() => onhover?.(null)}> + + + {format(row)} + + {clip(short(row.response), chars(Math.max(s, 120), LABEL_SIZE))} + + + {/each} + diff --git a/packages/results/src/lib/charts/highlight/WaffleLarge.svelte b/packages/results/src/lib/charts/highlight/WaffleLarge.svelte new file mode 100644 index 0000000..50972f2 --- /dev/null +++ b/packages/results/src/lib/charts/highlight/WaffleLarge.svelte @@ -0,0 +1,41 @@ + + + + {#each slots as i (i)} + onhover?.(null)} + /> + {/each} + diff --git a/packages/results/src/lib/charts/highlight/WaffleMedium.svelte b/packages/results/src/lib/charts/highlight/WaffleMedium.svelte new file mode 100644 index 0000000..88e3076 --- /dev/null +++ b/packages/results/src/lib/charts/highlight/WaffleMedium.svelte @@ -0,0 +1,42 @@ + + + + {#each slots as i (i)} + {@const column = i % layout.columns} + {@const line = Math.floor(i / layout.columns)} + + onhover?.(null)} + /> + {/each} + diff --git a/packages/results/src/lib/charts/index.ts b/packages/results/src/lib/charts/index.ts new file mode 100644 index 0000000..0ee97f0 --- /dev/null +++ b/packages/results/src/lib/charts/index.ts @@ -0,0 +1,71 @@ +// Chart id → component, keyed by a block's `chart:` in src/content/*.yaml. +// +// Nothing draws a heading: the page has one. Most return a single `` root +// so the same render works inline and as a file; the ./text ones have no `` +// and ./export.ts returns null for them, which ChartDownload treats as nothing +// to offer. +import Bar from './standard/Bar.svelte' +import BarClustered from './standard/BarClustered.svelte' +import BarStacked from './standard/BarStacked.svelte' +import Dumbbell from './standard/Dumbbell.svelte' +import Sankey from './standard/Sankey.svelte' +import Scatter from './standard/Scatter.svelte' +import Table from './standard/Table.svelte' + +import HeroBarStackedHorizontal from './hero/BarStackedHorizontal.svelte' +import HeroBarStackedMinmax from './hero/BarStackedMinmax.svelte' +import HeroBarStackedVertical from './hero/BarStackedVertical.svelte' +import HeroCube from './hero/Cube.svelte' +import HeroRank from './hero/Rank.svelte' +import HeroStat from './hero/Stat.svelte' +import HeroTreemap from './hero/Treemap.svelte' +import HeroWaffleLarge from './hero/WaffleLarge.svelte' +import HeroWaffleMedium from './hero/WaffleMedium.svelte' + +import HighlightBarStackedHorizontal from './highlight/BarStackedHorizontal.svelte' +import HighlightBarStackedMinmax from './highlight/BarStackedMinmax.svelte' +import HighlightBarStackedVertical from './highlight/BarStackedVertical.svelte' +import HighlightRank from './highlight/Rank.svelte' +import HighlightTreemap from './highlight/Treemap.svelte' +import HighlightTreemapSmall from './highlight/TreemapSmall.svelte' +import HighlightWaffleLarge from './highlight/WaffleLarge.svelte' +import HighlightWaffleMedium from './highlight/WaffleMedium.svelte' + +import TextStat from './text/Stat.svelte' + +import type { OnHover } from '$charts/utils/tooltip' +import type { Component } from 'svelte' + +export const charts: Record> = { + // Standard — the "Data" section of a chapter. + bar: Bar, + 'bar-stacked': BarStacked, + 'bar-clustered': BarClustered, + dumbbell: Dumbbell, + scatter: Scatter, + sankey: Sankey, + table: Table, + + // Hero (3D) — /[year]. One flat record rather than one per tier: the ids don't + // collide, and everything that walks this map wants all of them. + '3d-bar-stacked-horizontal': HeroBarStackedHorizontal, + '3d-bar-stacked-minmax': HeroBarStackedMinmax, + '3d-bar-stacked-vertical': HeroBarStackedVertical, + '3d-cube': HeroCube, + '3d-rank': HeroRank, + '3d-treemap': HeroTreemap, + '3d-waffle-large': HeroWaffleLarge, + '3d-waffle-medium': HeroWaffleMedium, + 'hero-stat': HeroStat, + + // Highlight (2D) — a chapter overview. + '2d-bar-stacked-horizontal': HighlightBarStackedHorizontal, + '2d-bar-stacked-minmax': HighlightBarStackedMinmax, + '2d-bar-stacked-vertical': HighlightBarStackedVertical, + '2d-treemap': HighlightTreemap, + '2d-treemap-small': HighlightTreemapSmall, + '2d-waffle-large': HighlightWaffleLarge, + '2d-waffle-medium': HighlightWaffleMedium, + rank: HighlightRank, + stat: TextStat, +} diff --git a/packages/results/src/lib/charts/standard/Bar.svelte b/packages/results/src/lib/charts/standard/Bar.svelte new file mode 100644 index 0000000..f1ded7d --- /dev/null +++ b/packages/results/src/lib/charts/standard/Bar.svelte @@ -0,0 +1,138 @@ + + + diff --git a/packages/results/src/lib/charts/standard/BarClustered.svelte b/packages/results/src/lib/charts/standard/BarClustered.svelte new file mode 100644 index 0000000..0fee0ab --- /dev/null +++ b/packages/results/src/lib/charts/standard/BarClustered.svelte @@ -0,0 +1,140 @@ + + + diff --git a/packages/results/src/lib/charts/standard/BarStacked.svelte b/packages/results/src/lib/charts/standard/BarStacked.svelte new file mode 100644 index 0000000..cdcd1f1 --- /dev/null +++ b/packages/results/src/lib/charts/standard/BarStacked.svelte @@ -0,0 +1,146 @@ + + + diff --git a/packages/results/src/lib/charts/standard/Dumbbell.svelte b/packages/results/src/lib/charts/standard/Dumbbell.svelte new file mode 100644 index 0000000..de15af9 --- /dev/null +++ b/packages/results/src/lib/charts/standard/Dumbbell.svelte @@ -0,0 +1,175 @@ + + + diff --git a/packages/results/src/lib/charts/standard/Sankey.svelte b/packages/results/src/lib/charts/standard/Sankey.svelte new file mode 100644 index 0000000..ee7bbfc --- /dev/null +++ b/packages/results/src/lib/charts/standard/Sankey.svelte @@ -0,0 +1,176 @@ + + + diff --git a/packages/results/src/lib/charts/standard/Scatter.svelte b/packages/results/src/lib/charts/standard/Scatter.svelte new file mode 100644 index 0000000..03e9103 --- /dev/null +++ b/packages/results/src/lib/charts/standard/Scatter.svelte @@ -0,0 +1,214 @@ + + + diff --git a/packages/results/src/lib/charts/standard/Table.svelte b/packages/results/src/lib/charts/standard/Table.svelte new file mode 100644 index 0000000..37387df --- /dev/null +++ b/packages/results/src/lib/charts/standard/Table.svelte @@ -0,0 +1,121 @@ + + + diff --git a/packages/results/src/lib/charts/svg/Attribution.svelte b/packages/results/src/lib/charts/svg/Attribution.svelte new file mode 100644 index 0000000..1b5c84c --- /dev/null +++ b/packages/results/src/lib/charts/svg/Attribution.svelte @@ -0,0 +1,31 @@ + + + + + + {survey}{#if site}  {site}{/if} + + +{terms} diff --git a/packages/results/src/lib/charts/svg/Glyph.svelte b/packages/results/src/lib/charts/svg/Glyph.svelte new file mode 100644 index 0000000..30afdb9 --- /dev/null +++ b/packages/results/src/lib/charts/svg/Glyph.svelte @@ -0,0 +1,15 @@ + + + + {@html glyph.markup} + diff --git a/packages/results/src/lib/charts/svg/Header.svelte b/packages/results/src/lib/charts/svg/Header.svelte new file mode 100644 index 0000000..ad13d87 --- /dev/null +++ b/packages/results/src/lib/charts/svg/Header.svelte @@ -0,0 +1,58 @@ + + +{#if layout.chips} + {#each chips as chip, i (i)} + + + + {chip.text} + + {/each} +{/if} + +{#each layout.lines as line, i (i)} + + {line} + +{/each} diff --git a/packages/results/src/lib/charts/svg/Legend.svelte b/packages/results/src/lib/charts/svg/Legend.svelte new file mode 100644 index 0000000..9e5cf45 --- /dev/null +++ b/packages/results/src/lib/charts/svg/Legend.svelte @@ -0,0 +1,20 @@ + + +{#each layout.items as item (item.i)} + + + {item.label} + +{/each} diff --git a/packages/results/src/lib/charts/svg/Stats.svelte b/packages/results/src/lib/charts/svg/Stats.svelte new file mode 100644 index 0000000..e0dab67 --- /dev/null +++ b/packages/results/src/lib/charts/svg/Stats.svelte @@ -0,0 +1,51 @@ + + +{#each placed as item, i (i)} + + + {item.text} +{/each} diff --git a/packages/results/src/lib/charts/svg/Wrap.svelte b/packages/results/src/lib/charts/svg/Wrap.svelte new file mode 100644 index 0000000..a5c31c0 --- /dev/null +++ b/packages/results/src/lib/charts/svg/Wrap.svelte @@ -0,0 +1,102 @@ + + + + + {label} + {#if description}{description}{/if} + + {#if brand} + + + {/if} + + {#if brand} +
+ + + {@render children?.()} + + {:else} + {@render children?.()} + {/if} + + {#if stats} + + {/if} + + {#if brand} + + {/if} +
diff --git a/packages/results/src/lib/charts/text/DataTable.svelte b/packages/results/src/lib/charts/text/DataTable.svelte new file mode 100644 index 0000000..c991154 --- /dev/null +++ b/packages/results/src/lib/charts/text/DataTable.svelte @@ -0,0 +1,86 @@ + + +{#if keys.length} +
+ + {#if caption} + + {/if} + + + + {#if controls} + + + {/if} + + {#each keys as key (key)} + + {/each} + + + + + {#each rows as row, i (i)} + {@const off = controls && selection!.hidden.includes(row.response)} + {@const on = controls && selection!.focus.includes(row.response)} + + + {#if controls} + + + + {/if} + + {#each keys as key (key)} + + {/each} + + {/each} + +
+ {title} — {figure.demographic?.name}, n = {n}{#if share} + ({share}){/if} +
ShowFocus + {headerFor(key, figure.name)} +
+ selection!.toggle(row.response)} /> + + + + {cell(row[key], key)} +
+
+{/if} diff --git a/packages/results/src/lib/charts/text/Quote.svelte b/packages/results/src/lib/charts/text/Quote.svelte new file mode 100644 index 0000000..28251ff --- /dev/null +++ b/packages/results/src/lib/charts/text/Quote.svelte @@ -0,0 +1,21 @@ + + +
+

+ “{block.headline}” +

+ + {#if cite} +
+
{@html cite}
+
+ {/if} +
diff --git a/packages/results/src/lib/charts/text/Stat.svelte b/packages/results/src/lib/charts/text/Stat.svelte new file mode 100644 index 0000000..705f6e9 --- /dev/null +++ b/packages/results/src/lib/charts/text/Stat.svelte @@ -0,0 +1,26 @@ + + +

+ {figures}{#if unit}{unit}{/if} +

+ +{#if row} +

{short(row.response)}

+{/if} diff --git a/packages/results/src/lib/charts/utils/chrome.ts b/packages/results/src/lib/charts/utils/chrome.ts new file mode 100644 index 0000000..b80c042 --- /dev/null +++ b/packages/results/src/lib/charts/utils/chrome.ts @@ -0,0 +1,94 @@ +// What an export adds around a chart. By context, not by prop: nothing on the +// page sets it, so `read()` is undefined there and every chart draws unchanged. +import { IconAnswer, IconInfo, IconLogo, IconUserStack } from '@stackoverflow/stacks-icons/icons' +import { getContext, setContext } from 'svelte' + +import { PAD, wrapText } from './theme' + +export interface Chrome { + brand?: boolean + year?: string + /** Absolute; drawn without its protocol. */ + url?: string + /** Responses to bring forward. Everything else dims. */ + focus?: string[] + /** Scale a share chart to its own largest value rather than to a full 100%. */ + normalise?: boolean + chapter?: string + section?: string + headline?: string +} + +// Fixed, not measured: a chart that followed its container would put a different +// drawing in the file than the one on screen. Below this the drawing scrolls. +export const CHART_WIDTH = 950 + +export const CHIP = 30 +export const CHIP_SIZE = 18 +export const TITLE_SIZE = 26 +export const STATS = 26 +export const FOOTER = 65 + +// Asked before the chart is drawn: everything below shifts down by `height`. +export function headerLayout(chrome: Chrome, width: number, margin = PAD) { + const chips = Boolean(chrome.chapter || chrome.section) + const top = margin + (chips ? CHIP + 24 : 0) + const lines = chrome.headline ? wrapText(chrome.headline, width - margin * 2, TITLE_SIZE, 2) : [] + + return { chips, top, lines, height: lines.length ? top + lines.length * (TITLE_SIZE * 1.2) + 10 : top } +} + +const KEY = Symbol('chart-chrome') + +// A getter, not a value: the preview re-renders as options change. +export const setChrome = (read: () => Chrome) => setContext(KEY, read) + +export const chromeReader = () => getContext<(() => Chrome) | undefined>(KEY) + +// `undefined` until something is focused, so an unhighlighted chart serialises +// byte for byte as it did before. Call at init; the return reads on each call. +export function useFocus() { + const read = chromeReader() + + return (response: unknown) => { + const focus = read?.().focus + if (!focus?.length) return undefined + return typeof response === 'string' && focus.includes(response) ? 1 : 0.22 + } +} + +// The top of a share chart's scale. Call at init; the return reads on each call. +export function useDomain() { + const read = chromeReader() + + // Never zero: an empty or all-zero set would divide the scale by nothing. + return (values: number[]) => (read?.().normalise ? Math.max(0.01, ...values) : 1) +} + +export interface Glyph { + width: number + height: number + markup: string +} + +// The root's size and `.svg-icon` class rely on CSS a downloaded file never +// sees, so both go and Glyph.svelte's fill is the only one that applies. +function flatten(src: string): Glyph { + const [, width, height] = src.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/)! + + const markup = src + .replace(/^[\s\S]*?]*>/, '') + .replace(/<\/svg>\s*$/, '') + // `fill="none"` is a hollow shape, not a colour, so it stays. + .replace(/\sfill="(?!none)[^"]*"/g, '') + + return { width: Number(width), height: Number(height), markup } +} + +export const logo = flatten(IconLogo) + +export const icons = { + respondents: flatten(IconUserStack), + share: flatten(IconAnswer), + note: flatten(IconInfo), +} diff --git a/packages/results/src/lib/charts/utils/export.ts b/packages/results/src/lib/charts/utils/export.ts new file mode 100644 index 0000000..68444ea --- /dev/null +++ b/packages/results/src/lib/charts/utils/export.ts @@ -0,0 +1,140 @@ +// Draws a chart to a PNG in the browser. The component is mounted off-screen at +// the requested options rather than lifted off the page, so an export can differ +// from what is on screen. +import { mount, unmount } from 'svelte' + +import ChartRoot from '$charts/ChartRoot.svelte' +import headlineFont from '$lib/assets/fonts/StackSansHeadline[wght].woff2?url' +import textFont from '$lib/assets/fonts/StackSansText[wght].woff2?url' + +import type { Chrome } from '$charts/utils/chrome' +import type { Component } from 'svelte' + +const SVG_NS = 'http://www.w3.org/2000/svg' + +interface Options { + figure: any + /** The SVG's own width. Charts lay out to it, so it changes the design, not the resolution. */ + width?: number + /** Raster multiplier. Same layout, more pixels. */ + scale?: number + chrome?: Chrome + /** Only the PNG needs this, and it costs ~137K of base64. */ + fonts?: boolean +} + +const FACES = [ + { family: 'Stack Sans Text', url: textFont }, + { family: 'Stack Sans Headline', url: headlineFont }, +] + +// Spreading a whole font into btoa at once overflows the stack. +function base64(buffer: ArrayBuffer) { + const bytes = new Uint8Array(buffer) + let binary = '' + for (let i = 0; i < bytes.length; i += 8192) binary += String.fromCharCode(...bytes.subarray(i, i + 8192)) + return btoa(binary) +} + +async function face({ family, url }: (typeof FACES)[number]) { + const response = await fetch(url) + if (!response.ok) throw new Error(`${response.status} for ${url}`) + + // `woff2`, not layout.css's `woff2-variations`: the variable axis still works + // and the plain format string is understood more widely. + const src = `url(data:font/woff2;base64,${base64(await response.arrayBuffer())}) format('woff2')` + + return `@font-face{font-family:'${family}';font-style:normal;font-weight:200 700;src:${src}}` +} + +let embedded: Promise | null = null + +// An SVG loaded into an `` is its own sandboxed document and cannot reach +// the page's webfonts, so a PNG falls back to the system font without these. +function fontFaces() { + embedded ??= Promise.all(FACES.map(face)) + .then((faces) => faces.join('')) + .catch((error) => { + // A fallback font is a much smaller problem than no PNG at all. + console.error('chart export: fonts not embedded', error) + return '' + }) + + return embedded +} + +// Figma reads `font-family` off the text node itself and ignores both the +// ancestor and the embedded `@font-face`, so it gets flattened on the way out. +function inlineFontFamily(svg: SVGElement) { + for (const text of svg.querySelectorAll('text')) { + // `closest` returns the node itself when it already carries one, so the + // headline faces keep theirs. + const family = text.closest('[font-family]')?.getAttribute('font-family') + if (family) text.setAttribute('font-family', family) + } +} + +export async function toSvg(Chart: Component, { figure, width = 800, chrome = {}, fonts = false }: Options) { + const faces = fonts ? await fontFaces() : '' + + const host = document.createElement('div') + host.setAttribute('aria-hidden', 'true') + host.style.cssText = 'position:fixed;top:0;left:-10000px;pointer-events:none' + document.body.append(host) + + const app = mount(ChartRoot, { target: host, props: { chart: Chart, figure, width, chrome } }) + + try { + const svg = host.querySelector('svg') + if (!svg) return null + + inlineFontFamily(svg) + + if (faces) { + const style = document.createElementNS(SVG_NS, 'style') + style.textContent = faces + svg.prepend(style) + } + + const box = svg.viewBox.baseVal + return { + markup: new XMLSerializer().serializeToString(svg), + width: box.width || width, + height: box.height || Number(svg.getAttribute('height')) || width, + } + } finally { + unmount(app) + host.remove() + } +} + +export async function toPng(Chart: Component, options: Options) { + const svg = await toSvg(Chart, { ...options, fonts: true }) + if (!svg) return null + + const scale = options.scale ?? 2 + + // A data URL rather than a blob URL: Safari treats a canvas that drew a + // blob-backed SVG as tainted and refuses to read it back. + const image = new Image() + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg.markup)}` + await image.decode() + + const canvas = document.createElement('canvas') + canvas.width = Math.round(svg.width * scale) + canvas.height = Math.round(svg.height * scale) + canvas.getContext('2d')?.drawImage(image, 0, 0, canvas.width, canvas.height) + + return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')) +} + +export function save(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + // Not synchronous: Firefox has been known to cancel the download if the URL + // goes away in the same task as the click. + setTimeout(() => URL.revokeObjectURL(url), 0) +} diff --git a/packages/results/src/lib/charts/utils/expressive.ts b/packages/results/src/lib/charts/utils/expressive.ts new file mode 100644 index 0000000..b030aa8 --- /dev/null +++ b/packages/results/src/lib/charts/utils/expressive.ts @@ -0,0 +1,64 @@ +// What the Hero (3D) and Highlight (2D) charts share. A feature arrives already +// narrowed by $lib/server/content.ts, so a component never filters again. +import { hierarchy, treemap, treemapSquarify } from 'd3-hierarchy' + +import { count, percent, px, shorten } from '$charts/utils/theme' + +export const rowsOf = (figure: any) => (figure.data ?? []).filter(Boolean) + +// The salary questions carry a named measure instead of a share, resolved by the +// loader into `{key, label, unit}`. Everything else is `pct`. +const valueOf = (figure: any) => figure.value ?? null + +export const amountOf = (figure: any) => { + const value = valueOf(figure) + return (row: any) => (value ? (row?.[value.key] ?? 0) : (row?.pct ?? 0)) +} + +export const formatOf = (figure: any) => { + const value = valueOf(figure) + const amount = amountOf(figure) + return (row: any) => (value ? `${value.unit ?? ''}${count(amount(row))}` : percent(amount(row))) +} + +// The chart in a sentence, for the ``. These forms draw few value labels — +// a waffle draws none — so without this the numbers exist only as geometry. +export function readingOf(figure: any, limit = 6) { + const rows = rowsOf(figure) + const short = shorten(figure) + const format = formatOf(figure) + + const said = rows.slice(0, limit).map((row: any) => `${short(row.response)} ${format(row)}`) + if (rows.length > limit) said.push(`and ${rows.length - limit} more`) + + return said.join(', ') +} + +// "1 in 5" — how many respondents one filled cell of a waffle stands for. +export const oneIn = (share: number) => Math.max(2, Math.round(1 / Math.max(share, 0.0001))) + +export function treemapCells(rows: any[], values: number[], width: number, height: number, padding = 4) { + const root = hierarchy({ children: rows.map((row, i) => ({ row, i, value: Math.max(values[i], 0) })) } as any) + .sum((d: any) => d.value ?? 0) + .sort((a: any, b: any) => (b.value ?? 0) - (a.value ?? 0)) + + treemap().tile(treemapSquarify).size([width, height]).paddingInner(padding).round(true)(root) + + return (root.leaves() as any[]).map((leaf) => ({ + row: leaf.data.row, + i: leaf.data.i, + x: leaf.x0, + y: leaf.y0, + width: Math.max(0, leaf.x1 - leaf.x0), + height: Math.max(0, leaf.y1 - leaf.y0), + })) +} + +// A near-square grid for `n` cells — the waffle's shape. +export function grid(n: number, width: number, gap: number) { + const columns = Math.min(n, Math.max(1, Math.round(Math.sqrt(n * 1.6)))) + const rows = Math.ceil(n / columns) + const size = px((width - gap * (columns - 1)) / columns) + + return { columns, rows, size, height: px(rows * size + gap * (rows - 1)) } +} diff --git a/packages/results/src/lib/charts/utils/iso.ts b/packages/results/src/lib/charts/utils/iso.ts new file mode 100644 index 0000000..af92f22 --- /dev/null +++ b/packages/results/src/lib/charts/utils/iso.ts @@ -0,0 +1,35 @@ +import { px } from '$charts/utils/theme' + +// Vertical rise per horizontal unit — the 2:1 look. +export const SKEW = 0.5 + +const UNIT = 100 +const H = UNIT / 2 +const R = UNIT * SKEW * 0.5 + +// Stand-in until the artwork lands; replacing these paths is the whole swap. +export const CUBE = { + top: `M${H} 0 L${UNIT} ${R} L${H} ${R * 2} L0 ${R} Z`, + left: `M0 ${R} L${H} ${R * 2} L${H} ${R * 2 + H} L0 ${R + H} Z`, + right: `M${UNIT} ${R} L${UNIT} ${R + H} L${H} ${R * 2 + H} L${H} ${R * 2} Z`, +} + +const CUBE_HEIGHT = (R * 2 + H) / UNIT + +export const cube = (x: number, y: number, size: number) => `translate(${px(x)} ${px(y)}) scale(${px(size / UNIT, 4)})` + +export const cubeHeight = (size: number) => size * CUBE_HEIGHT + +// Points rather than a scaled path: scaling an isometric path along one axis +// shears it, and a 30° top edge stops being 30°. +export function slab(x: number, y: number, w: number, h: number, d: number) { + const rise = px(d * SKEW) + const at = (a: number, b: number) => `${px(a)} ${px(b)}` + + return { + top: `M${at(x, y)} L${at(x + w, y)} L${at(x + w + d, y - rise)} L${at(x + d, y - rise)} Z`, + front: `M${at(x, y)} L${at(x + w, y)} L${at(x + w, y + h)} L${at(x, y + h)} Z`, + side: `M${at(x + w, y)} L${at(x + w + d, y - rise)} L${at(x + w + d, y + h - rise)} L${at(x + w, y + h)} Z`, + rise, + } +} diff --git a/packages/results/src/lib/charts/utils/rows.svelte.ts b/packages/results/src/lib/charts/utils/rows.svelte.ts new file mode 100644 index 0000000..3946a7d --- /dev/null +++ b/packages/results/src/lib/charts/utils/rows.svelte.ts @@ -0,0 +1,75 @@ +// Which responses an export draws, and which it brings forward. +// +// Owned here rather than in either component that needs it: the data table +// carries the controls, next to the numbers they act on, and ChartDownload reads +// the result to draw and to download. Call during component init. + +export function rowSelection(figure: () => any) { + // Keyed by group the way the page keys its own selection: responses differ + // between cuts, so a row hidden in one would silently apply to another. + let dropped = $state<{ group: string; responses: string[] }>({ group: '', responses: [] }) + let lit = $state<{ group: string; responses: string[] }>({ group: '', responses: [] }) + + const rows = $derived((figure().data ?? []).filter(Boolean)) + const responses = $derived(rows.map((row: any) => row.response)) + + // Hiding and focusing both work by response, so this is only offered where a + // response identifies one row. A sankey's are {source, target, value}, and a + // write-ins table repeats them once per use_type. + const listable = $derived( + rows.length > 1 && + responses.every((response: unknown) => typeof response === 'string') && + responses.every((response: string, i: number) => responses.indexOf(response) === i) + ) + + const group = $derived(figure().demographic?.id ?? '') + const hidden = $derived(dropped.group === group ? dropped.responses : []) + const focus = $derived(lit.group === group ? lit.responses : []) + + const kept = $derived(listable ? rows.filter((row: any) => !hidden.includes(row.response)) : rows) + + const without = (list: string[], response: string) => (list.includes(response) ? list.filter((r) => r !== response) : [...list, response]) + + return { + get rows() { + return rows + }, + get listable() { + return listable + }, + get hidden() { + return hidden + }, + get focus() { + return focus + }, + get kept() { + return kept + }, + /** The figure as the chart should draw it. */ + get shown() { + return hidden.length ? { ...figure(), data: kept } : figure() + }, + get touched() { + return hidden.length > 0 || focus.length > 0 + }, + + toggle(response: string) { + dropped = { group, responses: without(hidden, response) } + }, + + // Hiding a row drops its focus too: a highlight on something that isn't + // drawn would leave the count saying one thing and the chart another. + highlight(response: string) { + lit = { group, responses: without(focus, response) } + if (hidden.includes(response)) dropped = { group, responses: without(hidden, response) } + }, + + reset() { + dropped = { group, responses: [] } + lit = { group, responses: [] } + }, + } +} + +export type RowSelection = ReturnType diff --git a/packages/results/src/lib/charts/utils/theme.ts b/packages/results/src/lib/charts/utils/theme.ts new file mode 100644 index 0000000..5d2277e --- /dev/null +++ b/packages/results/src/lib/charts/utils/theme.ts @@ -0,0 +1,160 @@ +// Presentation attributes, not CSS classes: a chart also renders as a standalone +// .svg and into a canvas. The literal is what a file opened on its own resolves +// to. routes/layout.css defines the variables; keep the two in step. +const token = (name: string, fallback: string) => `var(--chart-${name}, ${fallback})` + +export const theme = { + background: token('bg', '#F0EFEE'), + ink: token('ink', '#201c1d'), + muted: token('muted', '#636261'), + rule: token('rule', '#c6d1e1'), + tint: token('tint', '#f9f8f8'), + accent: token('accent', '#ff5e00'), + + from: token('from', '#5074ef'), + to: token('to', '#ff5e00'), + + faceTop: token('face-top', '#ff5e00'), + faceSide: token('face-side', '#998b7a'), + + // The unfilled cells of a waffle. Heavier than `tint`, which disappears + // against the page at this size. + ghost: token('ghost', '#e5e4e3'), + + // Index with `series(i)` — a sankey carries more nodes than there are colours. + series: [ + token('series-1', '#5074ef'), + token('series-2', '#9e9cff'), + token('series-3', '#f39fff'), + token('series-4', '#ffcc01'), + token('series-5', '#85af24'), + token('series-6', '#ff5e00'), + token('series-7', '#998b7a'), + token('series-8', '#00165e'), + ], + + // The readable ink for each swatch above. + onSeries: [ + token('on-series-1', '#ffffff'), + token('on-series-2', '#201c1d'), + token('on-series-3', '#201c1d'), + token('on-series-4', '#201c1d'), + token('on-series-5', '#201c1d'), + token('on-series-6', '#ffffff'), + token('on-series-7', '#201c1d'), + token('on-series-8', '#ffffff'), + ], + + // Bare name, no stack and no quotes: Figma drops the font when handed a + // quoted, comma-separated list. + font: 'Stack Sans Text', + fontHeadline: 'Stack Sans Headline', +} + +export const series = (i: number) => theme.series[i % theme.series.length] + +export const onSeries = (i: number) => theme.onSeries[i % theme.onSeries.length] + +export const PAD = 15 + +// `places` is for the few values that aren't lengths: a scale factor multiplies +// up, so it needs more of them. +export function px(n: number, places = 2) { + const factor = 10 ** places + return Math.round(n * factor) / factor +} + +export const pxPath = (d: string) => d.replace(/\d+\.\d+/g, (n) => String(px(Number(n)))) + +// No text measurement where these charts render. Stack Sans advances 0.534 em +// per glyph, measured in a browser at 11, 12, 13 and 18px. +const GLYPH = 0.534 +const GLYPH_DIGITS = 0.66 + +export const textWidth = (text: string, fontSize: number) => px(text.length * fontSize * GLYPH) + +// An `` clips at its viewBox, so a height measured to the baseline cuts off +// every descender. +export const descent = (fontSize: number) => px(fontSize * 0.34) + +// `dominant-baseline` baked into `y`: Figma and most SVG converters ignore the +// attribute and read `y` as the alphabetic baseline. +export const middle = (y: number, fontSize: number) => px(y + fontSize * 0.35) + +export const hanging = (y: number, fontSize: number) => px(y + fontSize * 0.7) + +export const chars = (px: number, fontSize: number) => Math.max(1, Math.floor(px / (fontSize * GLYPH))) + +export const digitsWidth = (text: string, fontSize: number) => px(text.length * fontSize * GLYPH_DIGITS) + +export const labelGutter = (width: number) => Math.round(width * 0.2) + +const MIN_GUTTER_CHARS = 18 + +// Whether a row chart stacks its label above the bar rather than beside it. +export const stackRows = (width: number, fontSize: number) => chars(labelGutter(width), fontSize) < MIN_GUTTER_CHARS + +export const SWATCH = 9 +const LEGEND_ROW = 22 + +// Returns its own height so a chart reserves the space from the same layout +// ./Legend.svelte draws from. +export function legend(labels: any[], width: number) { + const items: { label: string; i: number; x: number; row: number }[] = [] + let x = 0 + let row = 0 + + for (const [i, label] of labels.entries()) { + const text = String(label) + const w = textWidth(text, 12) + SWATCH + 22 + if (x && x + w > width) { + row++ + x = 0 + } + items.push({ label: text, i, x: px(x), row }) + x += w + } + + return { items, rowHeight: LEGEND_ROW, height: (row + 1) * LEGEND_ROW + 10 } +} + +export function wrapText(text: string, width: number, fontSize: number, max = 2) { + const room = chars(width, fontSize) + const lines: string[] = [] + + for (const word of String(text ?? '') + .split(/\s+/) + .filter(Boolean)) { + const at = lines.length - 1 + + if (!lines.length) lines.push(word) + else if (lines[at].length + 1 + word.length <= room) lines[at] += ` ${word}` + else if (lines.length < max) lines.push(word) + else { + lines[at] = clip(`${lines[at]} ${word}`, room) + break + } + } + + return lines +} + +export const figureTitle = (figure: any) => figure.headline ?? figure.name ?? figure.question ?? figure.chart + +export const clip = (text: string, chars: number) => (text.length > chars ? `${text.slice(0, chars - 1).trimEnd()}…` : text) + +// The figure's short form of a response where it has one, resolved in +// $lib/server/content. +export const shorten = (figure: any) => (value: unknown) => { + const text = String(value ?? '') + return figure?.shorts?.[text] ?? text +} + +export function percent(share?: number) { + const value = (share ?? 0) * 100 + // Rounding a real-but-tiny share to "0%" reads as none at all. + if (value > 0 && value < 1) return '<1%' + return `${Math.round(value)}%` +} + +export const count = (value?: number) => (value ?? 0).toLocaleString('en-US') diff --git a/packages/results/src/lib/charts/utils/tooltip.ts b/packages/results/src/lib/charts/utils/tooltip.ts new file mode 100644 index 0000000..735717e --- /dev/null +++ b/packages/results/src/lib/charts/utils/tooltip.ts @@ -0,0 +1,18 @@ +// An ordinary prop, not a context: ./export.ts mounts a chart with no host to +// report to, and an absent `onhover` has to draw identically. +interface TooltipRow { + value: string + label?: string + color?: string +} + +export interface TooltipData { + // The full, unclipped category — often longer than the chart could draw. + title: string + rows: TooltipRow[] +} + +export type OnHover = (data: TooltipData | null, event?: PointerEvent) => void + +// Hit areas are this tall at least, so a thin mark is still catchable. +export const HIT = 24 diff --git a/packages/results/src/lib/components/BrandHeader.svelte b/packages/results/src/lib/components/BrandHeader.svelte index acd8097..8391b58 100644 --- a/packages/results/src/lib/components/BrandHeader.svelte +++ b/packages/results/src/lib/components/BrandHeader.svelte @@ -3,31 +3,28 @@ import { IconInfoFill, IconLogo } from '@stackoverflow/stacks-icons/icons' import Icon from '$lib/components/Icon.svelte' - import PromoBanner from '$lib/components/PromoBanner.svelte' let { label = null, children = null } = $props() -
-

- - Developer - Survey - {#if label}{label}{/if} +
+

+ + Developer + Survey + {#if label}{label}{/if}

{#if children} {@render children()} {:else} -

- +

+ The definitive report on the state of software development.

{/if}
- - diff --git a/packages/results/src/lib/seo/Seo.svelte b/packages/results/src/lib/components/Seo.svelte similarity index 62% rename from packages/results/src/lib/seo/Seo.svelte rename to packages/results/src/lib/components/Seo.svelte index cd56a24..a92a442 100644 --- a/packages/results/src/lib/seo/Seo.svelte +++ b/packages/results/src/lib/components/Seo.svelte @@ -2,8 +2,7 @@ import { asset } from '$app/paths' import { page } from '$app/state' - import { ogImage, siteDescription, siteName, siteUrl } from '$lib/constants' - import { markdownPath } from '$lib/markdown' + import { ogImage, siteDescription, siteName, siteUrl } from '$config' // Once per route: `` does not de-duplicate, so a layout *and* a // page yields two of each tag. Site-wide tags live in app.html. @@ -15,9 +14,12 @@ noindex?: boolean // Set false where a page has no .md twin, such as the error page. markdown?: boolean + // The page's schema.org @graph, built in scripts/data.js and carried on the + // payload. Absent on pages with no structured data, such as the error page. + graph?: any[] } - let { title, description = siteDescription, image = ogImage, type = 'website', noindex = false, markdown = true }: Props = $props() + let { title, description = siteDescription, image = ogImage, type = 'website', noindex = false, markdown = true, graph }: Props = $props() const fullTitle = $derived(title ? `${title} | ${siteName}` : siteName) const canonical = $derived(`${siteUrl}${page.url.pathname}`) @@ -25,7 +27,17 @@ // Absolute: the prerender crawler follows href on any tag, so a root-relative // one would be enqueued and validated as a page. - const markdownUrl = $derived(`${siteUrl}${markdownPath(page.url.pathname)}`) + const markdownUrl = $derived(`${siteUrl}${page.url.pathname === '/' ? '/index.md' : `${page.url.pathname}.md`}`) + + // A ld+json block is a data block, so the parser does not decode entities + // inside it — an entity would land in the JSON as literal text. The tag is + // assembled rather than written out: a literal closing tag would end this + // block for both the Svelte compiler and eslint's parser. + const jsonld = $derived( + graph?.length + ? ` @@ -33,6 +45,7 @@ + {#if markdown} {/if} @@ -51,4 +64,8 @@ {#if noindex} {/if} + + {#if jsonld} + {@html jsonld} + {/if} diff --git a/packages/results/src/lib/components/WhatWeAsked.svelte b/packages/results/src/lib/components/WhatWeAsked.svelte new file mode 100644 index 0000000..c95a68c --- /dev/null +++ b/packages/results/src/lib/components/WhatWeAsked.svelte @@ -0,0 +1,46 @@ + + + + +
+
{@html definition.titleHtml}
+ + {#if definition.carry_forward?.from} +

Options carried forward from {definition.carry_forward.from}.

+ {/if} + +

{askedMeta(definition)}

+ + + + + +
diff --git a/packages/results/src/lib/jsonld.ts b/packages/results/src/lib/jsonld.ts new file mode 100644 index 0000000..acd1aa6 --- /dev/null +++ b/packages/results/src/lib/jsonld.ts @@ -0,0 +1,260 @@ +// The schema.org `@graph` for every page. +// +// Built once by scripts/data.js and carried on each payload, so a route hands it +// straight to . It lives here rather than in the script because it is a +// front-end concern — what the page asserts about itself — and the script only +// happens to be where it is cheapest to run. + +import { dataset, licence, organisation, siteDescription, siteDescriptionLong, siteName, siteUrl } from '../../config.ts' + +const licenceUrl = licence.database.url + +const ids = { + organization: `${siteUrl}/#organization`, + website: `${siteUrl}/#website`, + catalog: `${siteUrl}/#catalog`, + dataset: (p: string) => `${siteUrl}${p}#dataset`, + page: (p: string) => `${siteUrl}${p}#webpage`, +} + +const organization = () => ({ + '@type': 'Organization', + '@id': ids.organization, + name: organisation.name, + legalName: licence.holder, + url: organisation.url, + logo: `${siteUrl}${organisation.logo}`, + sameAs: organisation.sameAs, +}) + +// No SearchAction: there is no site search, and claiming one is a Search Console +// warning waiting to happen. +const website = () => ({ + '@type': 'WebSite', + '@id': ids.website, + url: `${siteUrl}/`, + name: siteName, + description: siteDescription, + publisher: { '@id': ids.organization }, + inLanguage: dataset.language, +}) + +const breadcrumbs = (trail: { name: string; path: string }[]) => ({ + '@type': 'BreadcrumbList', + '@id': `${siteUrl}${trail[trail.length - 1].path}#breadcrumbs`, + itemListElement: trail.map((item, i: number) => ({ '@type': 'ListItem', position: i + 1, name: item.name, item: `${siteUrl}${item.path}` })), +}) + +const webPage = (page: any, mainEntity?: string) => ({ + '@type': 'WebPage', + '@id': ids.page(page.path), + url: `${siteUrl}${page.path}`, + name: page.title, + description: page.description, + isPartOf: { '@id': ids.website }, + inLanguage: dataset.language, + ...(mainEntity ? { mainEntity: { '@id': mainEntity } } : {}), + ...(page.markdown + ? { encoding: { '@type': 'MediaObject', encodingFormat: 'text/markdown', contentUrl: `${siteUrl}${page.markdown}` } } + : {}), +}) + +// Response-data URLs point at GitHub, so this takes either an absolute URL or a +// path on this site. +const download = (format: string, name: string, url: string) => ({ + '@type': 'DataDownload', + encodingFormat: format, + name, + contentUrl: url.startsWith('/') ? `${siteUrl}${url}` : url, +}) + +export function graphsFor({ survey, years, chapters, chapterPayloads }: any) { + const year = String(survey.settings.year) + const base = () => [organization(), website()] + const crumb = { name: 'Developer Survey', path: '/' } + + const home = [ + ...base(), + webPage({ path: '/', title: 'Stack Overflow Developer Survey', description: siteDescriptionLong, markdown: '/index.md' }, ids.catalog), + breadcrumbs([crumb]), + { + '@type': 'DataCatalog', + '@id': ids.catalog, + name: `${siteName} results`, + url: `${siteUrl}/`, + publisher: { '@id': ids.organization }, + license: licenceUrl, + dataset: years + .filter(({ results }: any) => results) + .map(({ year: y, results, data }: any) => ({ + '@type': 'Dataset', + '@id': ids.dataset(results.startsWith('/') ? results : `/${y}`), + name: `${siteName} ${y}`, + url: `${siteUrl}${results.startsWith('/') ? results : `/${y}`}`, + ...(data ? { distribution: [download('text/csv', `${y} response data (CSV)`, data)] } : {}), + })), + }, + ] + + const yearGraph = [ + ...base(), + webPage( + { + path: `/${year}`, + title: `Stack Overflow Developer Survey ${year}`, + description: survey.settings.description, + markdown: `/${year}.md`, + }, + ids.dataset(`/${year}`) + ), + breadcrumbs([crumb, { name: year, path: `/${year}` }]), + { + '@type': 'Dataset', + '@id': ids.dataset(`/${year}`), + name: `${siteName} ${year}`, + alternateName: `${year} Developer Survey`, + description: survey.settings.descriptionLong || survey.settings.description, + url: `${siteUrl}/${year}`, + identifier: `${siteUrl}/${year}`, + creator: { '@id': ids.organization }, + publisher: { '@id': ids.organization }, + includedInDataCatalog: { '@id': ids.catalog }, + license: licenceUrl, + isAccessibleForFree: true, + inLanguage: dataset.language, + // A bare year is the honest value: the content sheet carries no field + // dates. Add fieldStart/fieldEnd upstream and this becomes an interval. + temporalCoverage: year, + measurementTechnique: dataset.measurementTechnique, + creditText: `${siteName} ${year}`, + keywords: dataset.keywords, + hasPart: chapters.map(({ id }: any) => ({ '@id': ids.dataset(`/${year}/${id}/data`) })), + distribution: [download('text/markdown', `${year} results as markdown`, `/${year}.md`)], + }, + ] + + const methodologyPath = `/${year}/methodology` + const methodology = [ + ...base(), + webPage({ + path: methodologyPath, + title: `Methodology ${year}`, + description: 'How the survey was run and how the numbers were worked out.', + markdown: `${methodologyPath}.md`, + }), + breadcrumbs([crumb, { name: year, path: `/${year}` }, { name: 'Methodology', path: methodologyPath }]), + ] + + const chapter: Record = {} + const dataPage: Record = {} + const question: Record = {} + + for (const c of chapters) { + const chapterPath = `/${year}/${c.id}` + const dataPath = `${chapterPath}/data` + const figures = (chapterPayloads[c.id]?.sections ?? []).flatMap((s: any) => s.questions) + + chapter[c.id] = [ + ...base(), + webPage({ path: chapterPath, title: `${c.name} ${year}`, description: c.description, markdown: `${chapterPath}.md` }), + breadcrumbs([crumb, { name: year, path: `/${year}` }, { name: c.name, path: chapterPath }]), + { + '@type': 'Article', + '@id': `${chapterPath === '' ? '' : `${siteUrl}${chapterPath}`}#article`, + headline: c.name, + description: c.description, + articleSection: c.name, + url: `${siteUrl}${chapterPath}`, + isPartOf: { '@id': ids.website }, + author: { '@id': ids.organization }, + publisher: { '@id': ids.organization }, + about: { '@id': ids.dataset(dataPath) }, + inLanguage: dataset.language, + // No datePublished: the content sheet has no date, and a fabricated + // one is worse than none. + }, + ] + + const chapterDataset = { + '@type': 'Dataset', + '@id': ids.dataset(dataPath), + name: `${c.name} — ${siteName} ${year}`, + description: c.description || `Every figure in the ${c.name} chapter.`, + url: `${siteUrl}${dataPath}`, + isPartOf: { '@id': ids.dataset(`/${year}`) }, + creator: { '@id': ids.organization }, + license: licenceUrl, + isAccessibleForFree: true, + temporalCoverage: year, + // `propertyID` is the CSV column name. + variableMeasured: figures.map((q: any) => ({ + '@type': 'PropertyValue', + propertyID: q.dataId, + name: q.name ?? q.dataId, + description: q.definition?.title ?? q.question, + url: `${siteUrl}${dataPath}/${q.id}`, + })), + distribution: [download('text/markdown', `${c.name} data as markdown`, `${dataPath}.md`)], + } + + dataPage[c.id] = [ + ...base(), + webPage( + { + path: dataPath, + title: `${c.name} data ${year}`, + description: `Every figure in the ${c.name} chapter, with sample sizes.`, + markdown: `${dataPath}.md`, + }, + ids.dataset(dataPath) + ), + breadcrumbs([crumb, { name: year, path: `/${year}` }, { name: c.name, path: chapterPath }, { name: 'Data', path: dataPath }]), + chapterDataset, + ] + + for (const q of figures) { + const path = `${dataPath}/${q.id}` + const group = q.demographics[0] + const n = group.demographic.n + + question[`${c.id}/${q.id}`] = [ + ...base(), + webPage({ path, title: `${q.name} ${year}`, description: q.description, markdown: `${path}.md` }, ids.dataset(path)), + breadcrumbs([ + crumb, + { name: year, path: `/${year}` }, + { name: c.name, path: chapterPath }, + { name: 'Data', path: dataPath }, + { name: q.name, path }, + ]), + { + '@type': 'Dataset', + '@id': ids.dataset(path), + name: `${q.name} — ${siteName} ${year}`, + description: q.definition?.title ?? q.question, + url: `${siteUrl}${path}`, + isPartOf: { '@id': ids.dataset(dataPath) }, + creator: { '@id': ids.organization }, + license: licenceUrl, + isAccessibleForFree: true, + temporalCoverage: year, + // Rows with no response or no numeric percent are skipped: a table + // row carries rank/median/mode instead. + variableMeasured: group.data + .filter((row: any) => row.response != null && typeof row.pct === 'number') + .map((row: any) => ({ + '@type': 'PropertyValue', + propertyID: q.dataId, + name: row.response, + value: Number((row.pct * 100).toFixed(2)), + unitText: 'PERCENT', + description: `${row.count?.toLocaleString('en-US')} of ${n?.toLocaleString('en-US')} respondents (${group.demographic.name})`, + })), + distribution: [download('text/markdown', 'Every respondent group as markdown', `${path}.md`)], + }, + ] + } + } + + return { home, year: yearGraph, methodology, chapter, dataPage, question } +} diff --git a/packages/results/src/lib/labels.ts b/packages/results/src/lib/labels.ts new file mode 100644 index 0000000..39780e8 --- /dev/null +++ b/packages/results/src/lib/labels.ts @@ -0,0 +1,40 @@ +// What to call a column. +// +// The export ships raw column names and no presentation — deliberately, so the +// same column can't word itself differently across 283 datasets. One registry, +// read by the generator (which decides which column a figure plots) and by +// `$lib/table` for column headings. +// +// Eventually this arrives as a `measures` block in the export's index.json and +// the file becomes a reader. Until then it is the one place the wording lives. +// +// `label` is the axis title or tooltip line, where there is room to be exact. +// `short` is the table heading, where there isn't — a header clipped to +// "Percent of re…" says less than "Percent". + +export type Label = { key: string; label: string; unit: string } + +const REGISTRY: Record = { + count: { label: 'Respondents' }, + pct: { label: 'Percent of respondents', unit: '%', short: 'Percent' }, + median_yearly_salary_usd: { label: 'Median yearly salary (USD)', unit: '$', short: 'Median salary' }, + mean_yearly_salary_usd: { label: 'Mean yearly salary (USD)', unit: '$', short: 'Mean salary' }, + mean_prof_years: { label: 'Average years of professional experience', short: 'Years of experience' }, + rank: { label: 'Overall rank' }, + median_rank: { label: 'Median rank' }, + mode_rank: { label: 'Modal rank', short: 'Mode rank' }, + series: { label: 'Segment' }, +} + +const humanise = (key: string) => key.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase()) + +export const labelFor = (key: string): Label => ({ + key, + label: REGISTRY[key]?.label ?? humanise(key), + unit: REGISTRY[key]?.unit ?? '', +}) + +export const columnLabel = (key: string) => REGISTRY[key]?.short ?? REGISTRY[key]?.label ?? humanise(key) + +// The shape every row has. Anything else a row carries is a value worth labelling. +export const STRUCTURAL = new Set(['slice', 'series', 'response', 'count', 'pct']) diff --git a/packages/results/src/lib/markdown.ts b/packages/results/src/lib/markdown.ts deleted file mode 100644 index 71a0279..0000000 --- a/packages/results/src/lib/markdown.ts +++ /dev/null @@ -1,2 +0,0 @@ -// The markdown twin of a page, generated in src/routes/index.md. -export const markdownPath = (pathname: string) => (pathname === '/' ? '/index.md' : `${pathname.replace(/\/$/, '')}.md`) diff --git a/packages/results/src/lib/panel.ts b/packages/results/src/lib/panel.ts new file mode 100644 index 0000000..4995f59 --- /dev/null +++ b/packages/results/src/lib/panel.ts @@ -0,0 +1,14 @@ +import { goto, preloadData, pushState } from '$app/navigation' + +// Shallow routing: the address bar becomes the question's own URL, so the panel +// is shareable and Back closes it. A modified or middle click is left alone. +export async function openQuestion(event: MouseEvent, href: string) { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + + event.preventDefault() + + const result = await preloadData(href) + + if (result.type === 'loaded' && result.status === 200) pushState(href, { question: result.data as App.PageState['question'] }) + else goto(href) +} diff --git a/packages/results/src/lib/seo/JsonLd.svelte b/packages/results/src/lib/seo/JsonLd.svelte deleted file mode 100644 index 0787f2b..0000000 --- a/packages/results/src/lib/seo/JsonLd.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - - - {@html tag} - diff --git a/packages/results/src/lib/seo/graph.ts b/packages/results/src/lib/seo/graph.ts deleted file mode 100644 index 9e5e966..0000000 --- a/packages/results/src/lib/seo/graph.ts +++ /dev/null @@ -1,97 +0,0 @@ -// schema.org node builders, each a plain object with no `@context` — pages -// assemble them into one `@graph` and consumers de-duplicate by `@id`. -// -// A question is a `PropertyValue` in `Dataset.variableMeasured`, deliberately not -// `Question`/`suggestedAnswer`: that vocabulary is community Q&A and would assert -// respondents proposed the options. `QAPage` drives Google's Q&A rich result. -import { asset } from '$app/paths' - -import { licence, siteDescription, siteName, siteUrl } from '$lib/constants' - -const licenceUrl = licence.database.url - -export const ids = { - organization: `${siteUrl}/#organization`, - website: `${siteUrl}/#website`, - catalog: `${siteUrl}/#catalog`, - dataset: (path: string) => `${siteUrl}${path}#dataset`, - page: (path: string) => `${siteUrl}${path}#webpage`, -} - -export const organization = () => ({ - '@type': 'Organization', - '@id': ids.organization, - name: 'Stack Overflow', - legalName: licence.holder, - url: 'https://stackoverflow.com/', - logo: `${siteUrl}${asset('/apple-touch-icon.png')}`, - sameAs: [ - 'https://en.wikipedia.org/wiki/Stack_Overflow', - 'https://www.linkedin.com/company/stack-overflow/', - 'https://github.com/StackExchange', - 'https://twitter.com/stackoverflow', - ], -}) - -// No SearchAction: there is no site search, and claiming one is a Search Console -// warning waiting to happen. -export const website = () => ({ - '@type': 'WebSite', - '@id': ids.website, - url: `${siteUrl}/`, - name: siteName, - description: siteDescription, - publisher: { '@id': ids.organization }, - inLanguage: 'en', -}) - -export const breadcrumbs = (trail: { name: string; path: string }[]) => ({ - '@type': 'BreadcrumbList', - '@id': `${siteUrl}${trail.at(-1)!.path}#breadcrumbs`, - itemListElement: trail.map((item, i) => ({ - '@type': 'ListItem', - position: i + 1, - name: item.name, - item: `${siteUrl}${item.path}`, - })), -}) - -export const webPage = (page: { path: string; title: string; description: string; markdown?: string }, mainEntity?: string) => ({ - '@type': 'WebPage', - '@id': ids.page(page.path), - url: `${siteUrl}${page.path}`, - name: page.title, - description: page.description, - isPartOf: { '@id': ids.website }, - inLanguage: 'en', - ...(mainEntity ? { mainEntity: { '@id': mainEntity } } : {}), - ...(page.markdown - ? { encoding: { '@type': 'MediaObject', encodingFormat: 'text/markdown', contentUrl: `${siteUrl}${page.markdown}` } } - : {}), -}) - -// Response-data URLs point at GitHub, so this one takes either an absolute URL or -// a path on this site. -const download = (format: string, name: string, url: string) => ({ - '@type': 'DataDownload', - encodingFormat: format, - name, - contentUrl: url.startsWith('/') ? `${siteUrl}${url}` : url, -}) - -/** Every published year, as the catalogue the index page actually is. */ -export const catalog = (years: any[]) => ({ - '@type': 'DataCatalog', - '@id': ids.catalog, - name: `${siteName} results`, - url: `${siteUrl}/`, - publisher: { '@id': ids.organization }, - license: licenceUrl, - dataset: years.map(({ year, results, data }: any) => ({ - '@type': 'Dataset', - '@id': ids.dataset(results.startsWith('/') ? results : `/${year}`), - name: `${siteName} ${year}`, - url: `${siteUrl}${results.startsWith('/') ? results : `/${year}`}`, - ...(data ? { distribution: [download('text/csv', `${year} response data (CSV)`, data)] } : {}), - })), -}) diff --git a/packages/results/src/lib/seo/index.ts b/packages/results/src/lib/seo/index.ts deleted file mode 100644 index 905676d..0000000 --- a/packages/results/src/lib/seo/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as Seo } from './Seo.svelte' -export { default as JsonLd } from './JsonLd.svelte' -export * as graph from './graph' diff --git a/packages/results/src/lib/server/content.ts b/packages/results/src/lib/server/content.ts new file mode 100644 index 0000000..87c8e4c --- /dev/null +++ b/packages/results/src/lib/server/content.ts @@ -0,0 +1,26 @@ +// Looking up a generated payload by its route params. +// +// The join between the content sheet, the survey export and the question bank +// happens once in `scripts/data.js` and lands in `$generated`. Everything that +// isn't keyed by a param — settings, the chapter list, the prerender entries — +// is a plain field on `$generated/site.json`, so import that directly rather +// than going through here. +// +// Run `npm run data -w results` to rebuild, or just start Vite: the plugin in +// vite.config.js does it on every dev start and build. + +// The glob key is an absolute path; the tail after the payload kind is the id. +const load = (glob: Record, kind: string) => + Object.fromEntries( + Object.entries(glob).map(([path, payload]) => [path.slice(path.indexOf(kind) + kind.length).replace(/\.json$/, ''), payload]) + ) + +const chapters = load(import.meta.glob('$generated/chapter/*.json', { eager: true, import: 'default' }), '/chapter/') +const chapterData = load(import.meta.glob('$generated/data/*.json', { eager: true, import: 'default' }), '/data/') +const questions = load(import.meta.glob('$generated/question/*/*.json', { eager: true, import: 'default' }), '/question/') + +export const getChapter = (id: string) => chapters[id] ?? null + +export const getChapterData = (id: string) => chapterData[id] ?? null + +export const getQuestion = (chapterId: string, slug: string) => questions[`${chapterId}/${slug}`] ?? null diff --git a/packages/results/src/lib/server/llms.ts b/packages/results/src/lib/server/llms.ts new file mode 100644 index 0000000..93844ab --- /dev/null +++ b/packages/results/src/lib/server/llms.ts @@ -0,0 +1,287 @@ +// Every published page, once, and its markdown twin. +// +// The sitemap, llms.txt, the .md endpoints and their prerender entries all +// derive from `listPages()`, so the four cannot drift. The twins exist for +// models rather than people — a browser downloads text/markdown rather than +// rendering it, and the HTML page is what a person follows. +import { error } from '@sveltejs/kit' + +import years from '$archive/index.json' + +import { licence, siteDescription, siteName, siteUrl } from '$config' +import { ofSurvey, toMarkdown } from '$lib/table' + +import site from '$generated/site.json' +import yearPayload from '$generated/year.json' + +import { getChapter, getChapterData, getQuestion } from './content' + +const year = String(site.settings.year) + +export type PageKind = 'home' | 'year' | 'chapter' | 'methodology' | 'data' | 'question' + +export interface PageRef { + kind: PageKind + path: string + markdown: string + title: string + description: string + // Passed to a .md endpoint's `entries` verbatim. + params: Record +} + +const ref = (kind: PageKind, path: string, title: string, description: string, params: Record): PageRef => ({ + kind, + path, + markdown: path === '/' ? '/index.md' : `${path}.md`, + title, + description, + params, +}) + +export function listPages(): PageRef[] { + const chapters = site.chapters + + return [ + ref('home', '/', siteName, siteDescription, {}), + ref('year', `/${year}`, `${siteName} ${year}`, site.settings.description, { year }), + ...chapters.flatMap(({ id, name, description }: any) => [ + ref('chapter', `/${year}/${id}`, `${name} ${year}`, description?.trim() || `The ${name} chapter.`, { year, page: id }), + ref('data', `/${year}/${id}/data`, `${name} data ${year}`, `Every figure in the ${name} chapter, with sample sizes.`, { + year, + chapter: id, + }), + ...((site.questions as Record)[id] ?? []).map(({ slug, name: question }: any) => + ref('question', `/${year}/${id}/data/${slug}`, `${question} ${year}`, `${question}, by respondent group.`, { + year, + chapter: id, + question: slug, + }) + ), + ]), + ref('methodology', `/${year}/methodology`, `Methodology ${year}`, 'How the survey was run and how the numbers were worked out.', { + year, + page: 'methodology', + }), + ] +} + +const join = (...parts: (string | null | undefined | false)[]) => parts.filter(Boolean).join('\n\n') + +const count = (n: number | null | undefined) => n?.toLocaleString('en-US') ?? '—' + +const frontMatter = (page: PageRef, extra: Record = {}) => + [ + '---', + `title: ${JSON.stringify(page.title)}`, + `url: ${siteUrl}${page.path}`, + `year: ${year}`, + ...Object.entries(extra).map(([key, value]) => `${key}: ${JSON.stringify(value)}`), + `licence: ${JSON.stringify(`${licence.database.name} (${licence.database.url})`)}`, + '---', + ].join('\n') + +// Same wording as the figcaption and the table caption, from the same helper. +function nLine(demographic: any) { + const share = ofSurvey(demographic?.share) + return `n = ${count(demographic?.n)}${share ? ` (${share})` : ''}` +} + +// From the bank where it resolves, the export otherwise. +function asked(block: any) { + const d = block.definition + if (!d) return `Asked as: ${block.question} (\`${block.dataId}\`)` + + const meta = [`\`${d.id}\``, d.type.replace(/_/g, ' '), d.required ? 'required' : 'optional', `v${d.version}`] + return `Asked as: ${d.title} (${meta.join(', ')})` +} + +function figure(block: any, heading: string) { + // A quote is its own copy, so it drops the caller's heading rather than + // printing the same text twice. + if (block.kind === 'quote') return join(`> ${block.headline}`, block.description) + + // A question carries its cuts and nothing flat; a promoted figure is already + // narrowed to the one cut the sheet asked for. + const shown = block.demographics ? { ...block, ...block.demographics[0] } : block + + return join( + heading, + shown.description, + asked(shown), + `${shown.demographic?.name} · ${nLine(shown.demographic)}`, + shown.subtext && `_${shown.subtext}_`, + toMarkdown(shown.data) + ) +} + +function home(page: PageRef) { + const [current, ...past] = years as any[] + + return join( + frontMatter(page), + `# ${siteName}`, + site.settings.descriptionLong, + `## ${current.year}`, + `- [Results](${siteUrl}/${year})\n- [Methodology](${siteUrl}/${year}/methodology)`, + '## Past years', + past + .map(({ year: y, results, data }: any) => { + const link = results.startsWith('/') ? `${siteUrl}${results}` : results + return `- [${y} results](${link})${data ? ` · [responses (CSV)](${data})` : ''}` + }) + .join('\n'), + `Cite as: ${siteName}, ${licence.holder}` + ) +} + +function yearPage(page: PageRef) { + const chapters = yearPayload.chapters + + return join( + frontMatter(page), + `# ${siteName} ${year}`, + site.settings.descriptionLong, + '## Chapters', + chapters + .map( + ({ id, name, description }: any) => + `- [${name}](${siteUrl}/${year}/${id}) · [all data](${siteUrl}/${year}/${id}/data)${description?.trim() ? `: ${description.trim()}` : ''}` + ) + .join('\n'), + '## Headline figures', + chapters.flatMap((chapter: any) => chapter.heroes.map((hero: any) => figure(hero, `### ${chapter.name}`))).join('\n\n') + ) +} + +function chapterPage(page: PageRef, id: string) { + const chapter = getChapter(id) + if (!chapter) return null + + return join( + frontMatter(page, { chapter: id }), + `# ${chapter.name} ${year}`, + chapter.description, + '## Highlights', + chapter.highlights.map((h: any) => figure(h, `### ${h.headline ?? h.name}`)).join('\n\n'), + `Every figure in this chapter: ${siteUrl}/${year}/${id}/data.md` + ) +} + +function dataPage(page: PageRef, id: string) { + const chapter = getChapterData(id) + if (!chapter) return null + + return join( + frontMatter(page, { chapter: id }), + `# ${chapter.name} data ${year}`, + chapter.description, + chapter.sections + .map((section: any, i: number) => + join( + `## ${chapter.index}.${i + 1} ${section.name}`, + section.questions + .map((q: any) => + join( + figure(q, `### ${q.name ?? q.dataId}`), + q.kind === 'figure' && `Every respondent group: ${siteUrl}/${year}/${id}/data/${q.id}.md` + ) + ) + .join('\n\n') + ) + ) + .join('\n\n') + ) +} + +function questionPage(page: PageRef, chapterId: string, slug: string) { + // The payload is `{ question, chapter }` — the same object the route returns. + const question: any = getQuestion(chapterId, slug)?.question + if (!question || question.kind !== 'figure') return null + + return join( + frontMatter(page, { chapter: chapterId, question: question.dataId, section: question.sectionName }), + `# ${question.name}`, + question.description, + asked(question), + question.definition?.options && + join( + '## Options offered', + question.definition.options + .map( + (o: any, i: number) => + `${i + 1}. ${typeof o === 'string' ? o : o.label}${typeof o !== 'string' && o.text_entry ? ' (with free-text entry)' : ''}` + ) + .join('\n') + ), + '## Results', + // `subtext` describes the question, not the cut, so it sits above the groups + // rather than being repeated under each one. + question.subtext && `_${question.subtext}_`, + question.demographics.map((d: any) => join(`### ${d.demographic.name}`, nLine(d.demographic), toMarkdown(d.data))).join('\n\n'), + `In context: ${siteUrl}/${year}/${chapterId}/data.md` + ) +} + +function methodologyPage(page: PageRef) { + const data: Record = site.methodology + const scalars = Object.entries(data).filter(([, value]) => typeof value !== 'object') + + return join( + frontMatter(page), + `# Methodology ${year}`, + 'How the survey was run, who answered it, and how the numbers here were worked out.', + scalars.length > 0 && + join( + '## At a glance', + scalars + .map( + ([key, value]) => + `- ${key.replace(/([a-z])([A-Z])/g, '$1 $2')}: ${typeof value === 'number' ? value.toLocaleString('en-US') : value}` + ) + .join('\n') + ), + `Response data is released under the ${licence.database.name} (${licence.database.url}).`, + `Cite as: ${siteName}, ${licence.holder}` + ) +} + +function renderMarkdown(page: PageRef): string | null { + const { chapter, question } = page.params + + switch (page.kind) { + case 'home': + return home(page) + case 'year': + return yearPage(page) + case 'chapter': + return chapterPage(page, page.params.page) + case 'data': + return dataPage(page, chapter) + case 'question': + return questionPage(page, chapter, question) + case 'methodology': + return methodologyPage(page) + } +} + +// Nothing links to the .md twins as routes — the alternate link is absolute, so +// the crawler skips it — so every endpoint declares its own entries. Loosely +// typed because each route wants its own RouteParams. +export const entriesFor = + (...kinds: PageKind[]) => + (): any[] => + listPages() + .filter((page) => kinds.includes(page.kind)) + .map(({ params }) => params) + +export function markdown(kinds: PageKind[], params: Record) { + const match = (page: PageRef) => kinds.includes(page.kind) && Object.entries(page.params).every(([key, value]) => params[key] === value) + + const page = listPages().find(match) + const body = page && renderMarkdown(page) + + if (!body) error(404, 'No markdown for this page') + + return new Response(`${body}\n`, { headers: { 'content-type': 'text/markdown; charset=utf-8' } }) +} diff --git a/packages/results/src/lib/table.ts b/packages/results/src/lib/table.ts new file mode 100644 index 0000000..9ee2951 --- /dev/null +++ b/packages/results/src/lib/table.ts @@ -0,0 +1,79 @@ +// A figure's rows as a table, for the markdown twins and the screen-reader table +// beside each chart. Shared so the two cannot disagree. +import { csvFormat } from 'd3-dsv' + +import { columnLabel } from '$lib/labels' + +// Key-union rather than a switch on chart type: a question can carry any of the +// named columns beside `count` and `pct`, so a new one renders without a branch. +export const columns = (rows: any[]) => [...new Set(rows.filter(Boolean).flatMap((row) => Object.keys(row)))] + +// Whole numbers: the smallest cut in the export is 1.6%, so nothing rounds to 0%. +export const ofSurvey = (share: number | null | undefined) => (typeof share === 'number' ? `${Math.round(share * 100)}%` : null) + +// `pct` is the only 0–1 fraction in the export; everything else is a count, a +// currency amount or a rank, and reads as itself. +export function cell(value: any, key: string): string { + if (typeof value !== 'number') return String(value ?? '') + return key === 'pct' ? `${(value * 100).toFixed(1)}%` : value.toLocaleString('en-US') +} + +// The heading a column gets. `response` is whatever the question is about, so it +// borrows the question's own name rather than saying "Response". +export const headerFor = (key: string, questionName?: string | null) => (key === 'response' ? questionName || 'Response' : columnLabel(key)) + +const rowsOf = (rows: any[]) => (rows ?? []).filter(Boolean) + +/** + * Flat `(response, series)` rows regrouped into one entry per response, with a + * slot per series in `series` order — `null` where the export has no row for + * that combination. + * + * The four multi-series charts all need exactly this, and the export writes rows + * response-major, so first appearance is the display order. + */ +export function bySeries(rows: any[], series: string[]) { + const order: string[] = [] + const index: Record> = {} + + for (const row of rowsOf(rows)) { + const response = String(row.response ?? '') + if (!index[response]) { + index[response] = {} + order.push(response) + } + index[response][String(row.series ?? '')] = row + } + + return order.map((response) => ({ response, cells: series.map((name) => index[response][name] ?? null) })) +} + +export function toMarkdown(rows: any[]) { + const clean = rowsOf(rows) + if (!clean.length) return '' + + const keys = columns(clean) + const escape = (value: string) => value.replace(/\|/g, '\\|').replace(/\s*\n\s*/g, ' ') + + return [ + `| ${keys.join(' | ')} |`, + `| ${keys.map(() => '---').join(' | ')} |`, + ...clean.map((row) => `| ${keys.map((key) => escape(cell(row[key], key))).join(' | ')} |`), + ].join('\n') +} + +// Squared off to one key set, with the raw values rather than `cell`'s display +// strings: "1,234" and "12.3%" arrive in a spreadsheet as text. +export function toRows(rows: any[]) { + const clean = rowsOf(rows) + const keys = columns(clean) + + return clean.map((row) => Object.fromEntries(keys.map((key) => [key, (row[key] ?? null) as any]))) +} + +// d3-dsv rather than a hand-rolled quoter: same field escaping, already in the +// project, and small enough to ship — this runs in the browser on download. +export function toCsv(rows: any[]) { + const clean = rowsOf(rows) + return clean.length ? csvFormat(toRows(rows), columns(clean)) : '' +} diff --git a/packages/results/src/routes/+error.svelte b/packages/results/src/routes/+error.svelte index 63f00ba..67687ee 100644 --- a/packages/results/src/routes/+error.svelte +++ b/packages/results/src/routes/+error.svelte @@ -2,7 +2,7 @@ import { resolve } from '$app/paths' import { page } from '$app/state' import Icon from '$lib/components/Icon.svelte' - import { Seo } from '$lib/seo' + import Seo from '$lib/components/Seo.svelte' import { SpotError404, SpotError500 } from '@stackoverflow/stacks-icons/spots' @@ -14,11 +14,11 @@ -
+
-

{heading}

+

{heading}

{#if message}

{message}

diff --git a/packages/results/src/routes/+layout.svelte b/packages/results/src/routes/+layout.svelte index 9914bc0..7725d93 100644 --- a/packages/results/src/routes/+layout.svelte +++ b/packages/results/src/routes/+layout.svelte @@ -1,5 +1,5 @@ diff --git a/packages/results/src/routes/+page.server.ts b/packages/results/src/routes/+page.server.ts index 24ce4aa..9cdea78 100644 --- a/packages/results/src/routes/+page.server.ts +++ b/packages/results/src/routes/+page.server.ts @@ -1,4 +1,5 @@ +import site from '$generated/site.json' import years from '$archive/index.json' import type { PageServerLoad } from './$types' -export const load: PageServerLoad = () => ({ years }) +export const load: PageServerLoad = () => ({ settings: site.settings, years, jsonld: site.jsonld }) diff --git a/packages/results/src/routes/+page.svelte b/packages/results/src/routes/+page.svelte index ccf4f2d..4d03f0e 100644 --- a/packages/results/src/routes/+page.svelte +++ b/packages/results/src/routes/+page.svelte @@ -1,13 +1,12 @@ - {#snippet links(entry: Year)}
-
{/snippet} - - +
@@ -68,34 +50,33 @@

- + {current.year}

-
+

{siteDescriptionLong}

- {@render links(current)}
-
+

Past years

All data from previous years can be found in the GitHub repository.

-