add world opinions project
All checks were successful
Test and deploy staging / staging (push) Successful in 1m49s
All checks were successful
Test and deploy staging / staging (push) Successful in 1m49s
World map with switchable layers (what "liberal" means, what left vs right is about, traditional/secular and survival/self-expression values), country cards, a shared-scale party compass, a values scatter and national narratives of major events. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
23
bun.lock
23
bun.lock
@@ -11,12 +11,15 @@
|
||||
"@maptiler/sdk": "^4.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"ai": "^7.0.85",
|
||||
"d3-geo": "^3.1.1",
|
||||
"drizzle-orm": "^1.0.0-rc.4",
|
||||
"highlight.js": "^11.12.0",
|
||||
"marked": "^18.0.11",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postgres": "^3.4.9",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
"zod": "^4.5.4",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -28,8 +31,10 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/d3-geo": "^3.1.1",
|
||||
"@types/eslint": "9.6.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@typescript/native": "npm:typescript@7",
|
||||
"better-auth": "^1.7.2",
|
||||
"bits-ui": "^2.19.0",
|
||||
@@ -479,6 +484,8 @@
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
||||
@@ -497,6 +504,10 @@
|
||||
|
||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||
|
||||
"@types/topojson-client": ["@types/topojson-client@3.1.5", "", { "dependencies": { "@types/geojson": "*", "@types/topojson-specification": "*" } }, "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw=="],
|
||||
|
||||
"@types/topojson-specification": ["@types/topojson-specification@1.0.5", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.68.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/type-utils": "8.68.0", "@typescript-eslint/utils": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.68.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.68.0", "@typescript-eslint/types": "8.68.0", "@typescript-eslint/typescript-estree": "8.68.0", "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ=="],
|
||||
@@ -619,6 +630,8 @@
|
||||
|
||||
"cmdk-sv": ["cmdk-sv@0.0.19", "", { "dependencies": { "bits-ui": "^0.21.12", "nanoid": "^5.0.7" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.1" } }, "sha512-Vm6+/up5nQbwGmNw0uYFbXTrbpF460/FhMohTskzzszMtIR/u/78haucSmJWjjp9bW0wxpHXZWa6fk+Lj1tKnA=="],
|
||||
|
||||
"commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||
|
||||
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
|
||||
@@ -631,6 +644,10 @@
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
@@ -747,6 +764,8 @@
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
@@ -1005,6 +1024,8 @@
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
|
||||
|
||||
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
@@ -1051,6 +1072,8 @@
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"world-atlas": ["world-atlas@2.0.2", "", {}, "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="],
|
||||
|
||||
161
package-lock.json
generated
161
package-lock.json
generated
@@ -14,12 +14,15 @@
|
||||
"@maptiler/sdk": "^4.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"ai": "^7.0.85",
|
||||
"d3-geo": "^3.1.1",
|
||||
"drizzle-orm": "^1.0.0-rc.4",
|
||||
"highlight.js": "^11.12.0",
|
||||
"marked": "^18.0.11",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postgres": "^3.4.9",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
"zod": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -31,8 +34,10 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/d3-geo": "^3.1.1",
|
||||
"@types/eslint": "9.6.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@typescript/native": "npm:typescript@7",
|
||||
"better-auth": "^1.7.2",
|
||||
"bits-ui": "^2.19.0",
|
||||
@@ -3207,6 +3212,72 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
@@ -3300,6 +3371,16 @@
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -3370,6 +3451,27 @@
|
||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/topojson-client": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*",
|
||||
"@types/topojson-specification": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/topojson-specification": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.68.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
|
||||
@@ -4549,6 +4651,12 @@
|
||||
"svelte": ">=3 <5"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commondir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||
@@ -4605,6 +4713,30 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -6017,6 +6149,15 @@
|
||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.2",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
|
||||
@@ -7919,6 +8060,20 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -8679,6 +8834,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/world-atlas": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
|
||||
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -34,8 +34,10 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/d3-geo": "^3.1.1",
|
||||
"@types/eslint": "9.6.1",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"@typescript/native": "npm:typescript@7",
|
||||
"better-auth": "^1.7.2",
|
||||
"bits-ui": "^2.19.0",
|
||||
@@ -68,12 +70,15 @@
|
||||
"@maptiler/sdk": "^4.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"ai": "^7.0.85",
|
||||
"d3-geo": "^3.1.1",
|
||||
"drizzle-orm": "^1.0.0-rc.4",
|
||||
"highlight.js": "^11.12.0",
|
||||
"marked": "^18.0.11",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"postgres": "^3.4.9",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
"zod": "^4.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ export const projects = [
|
||||
href: '/projects/derby',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'world opinions',
|
||||
subtitle: 'ideologies, values and biases across countries',
|
||||
href: '/projects/world-opinions',
|
||||
external: false
|
||||
},
|
||||
{
|
||||
name: 'money tracker',
|
||||
subtitle: 'a manual ledger for accounts, bills and utilities',
|
||||
|
||||
@@ -48,6 +48,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
||||
'/projects/spotify-beatmap-finder': {
|
||||
title: 'osu! map finder',
|
||||
description: 'Find osu! beatmaps for Spotify songs, albums and playlists.'
|
||||
},
|
||||
'/projects/world-opinions': {
|
||||
title: 'World Opinions',
|
||||
description:
|
||||
'How political words, parties, values and national histories differ across the world.'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
||||
'/projects/game-tracker': 'Game Tracker',
|
||||
'/projects/money-tracker': 'Money Tracker',
|
||||
'/projects/spotify-beatmap-finder': 'osu! map finder',
|
||||
'/projects/progress': 'Progress'
|
||||
'/projects/progress': 'Progress',
|
||||
'/projects/world-opinions': 'World Opinions'
|
||||
};
|
||||
const cleaned = path.replace(/\/$/, '') || '/';
|
||||
const title =
|
||||
|
||||
95
src/lib/world-opinions/CountryPanel.svelte
Normal file
95
src/lib/world-opinions/CountryPanel.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import EyeOff from 'lucide-svelte/icons/eye-off';
|
||||
import Swords from 'lucide-svelte/icons/swords';
|
||||
import { countryById, divides, liberalMeanings, profileById, regions } from './data';
|
||||
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
let country = $derived(countryById.get(id));
|
||||
let profile = $derived(profileById.get(id));
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
left: 'var(--wo-s1)',
|
||||
market: 'var(--wo-s2)',
|
||||
western: 'var(--wo-s3)',
|
||||
economy: 'var(--wo-s1)',
|
||||
culture: 'var(--wo-s2)',
|
||||
history: 'var(--wo-s3)',
|
||||
none: 'var(--wo-none)'
|
||||
};
|
||||
// Map a -2.5…2.5 score onto the gauge.
|
||||
const position = (value: number) => `${Math.min(Math.max((value + 2.5) / 5, 0), 1) * 100}%`;
|
||||
</script>
|
||||
|
||||
{#if country}
|
||||
<div class="wo-card">
|
||||
<div class="mb-4 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 class="text-2xl font-semibold">{country.name}</h3>
|
||||
<span class="text-sm text-[var(--site-muted)]">{regions[country.region]}</span>
|
||||
</div>
|
||||
|
||||
{#if profile}<p class="mb-5 text-lg">{profile.center}</p>{/if}
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<div class="space-y-4">
|
||||
{#if country.values}
|
||||
{#each [{ label: ['traditional', 'secular'], value: country.values.y }, { label: ['survival', 'self-expression'], value: country.values.x }] as gauge (gauge.label[0])}
|
||||
<div>
|
||||
<div class="wo-gauge" aria-hidden="true">
|
||||
<span class="wo-gauge-marker" style:left={position(gauge.value)}></span>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between text-xs text-[var(--site-muted)]">
|
||||
<span>{gauge.label[0]}</span><span>{gauge.label[1]}</span>
|
||||
</div>
|
||||
<span class="sr-only">{gauge.label[0]} to {gauge.label[1]}: {gauge.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
{#if country.liberal}
|
||||
<span class="wo-tag"
|
||||
><span class="wo-swatch" style:background={colors[country.liberal]}></span>"liberal" = {liberalMeanings[
|
||||
country.liberal
|
||||
]}</span
|
||||
>
|
||||
{/if}
|
||||
{#if country.divide}
|
||||
<span class="wo-tag"
|
||||
><span class="wo-swatch" style:background={colors[country.divide]}></span>left vs
|
||||
right: {divides[country.divide]}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if profile}
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="wo-tag-label"><Swords size={16} aria-hidden="true" />fights over</span>
|
||||
{#each profile.fights as fight (fight)}
|
||||
<span class="wo-tag">{fight}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="wo-tag-label"><EyeOff size={16} aria-hidden="true" />blind spots</span>
|
||||
{#each profile.blindSpots as spot (spot)}
|
||||
<span class="wo-tag wo-tag-dashed">{spot}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if profile}
|
||||
<ol class="wo-timeline mt-8" aria-label="History that shaped it">
|
||||
{#each profile.timeline as moment (moment.year)}
|
||||
<li>
|
||||
<span class="wo-timeline-year">{moment.year}</span>
|
||||
<span class="wo-timeline-dot" aria-hidden="true"></span>
|
||||
<span class="wo-timeline-label">{moment.label}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
224
src/lib/world-opinions/CultureMap.svelte
Normal file
224
src/lib/world-opinions/CultureMap.svelte
Normal file
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { countries, regions, type RegionId } from './data';
|
||||
|
||||
const culturalMap = countries.flatMap((country) =>
|
||||
country.values ? [{ ...country, x: country.values.x, y: country.values.y }] : []
|
||||
);
|
||||
|
||||
let { selected = null, onselect }: { selected?: string | null; onselect?: (id: string) => void } =
|
||||
$props();
|
||||
|
||||
const width = 640;
|
||||
const height = 440;
|
||||
const pad = { top: 28, right: 20, bottom: 44, left: 48 };
|
||||
const domain = { x: [-2, 2.8], y: [-2.2, 2.2] } as const;
|
||||
const sx = (value: number) =>
|
||||
pad.left +
|
||||
((value - domain.x[0]) / (domain.x[1] - domain.x[0])) * (width - pad.left - pad.right);
|
||||
const sy = (value: number) =>
|
||||
height -
|
||||
pad.bottom -
|
||||
((value - domain.y[0]) / (domain.y[1] - domain.y[0])) * (height - pad.top - pad.bottom);
|
||||
const ticks = [-2, -1, 0, 1, 2];
|
||||
|
||||
const usedRegions = [...new Set(culturalMap.map((point) => point.region))];
|
||||
let region = $state<RegionId | 'all'>('all');
|
||||
let hovered = $state<string | null>(null);
|
||||
let showTable = $state(false);
|
||||
|
||||
const highlighted = (id: string, pointRegion: RegionId) =>
|
||||
id === selected || (region !== 'all' && pointRegion === region);
|
||||
// Background dots first so highlighted ones paint on top.
|
||||
let ordered = $derived(
|
||||
[...culturalMap].sort(
|
||||
(a, b) => Number(highlighted(a.id, a.region)) - Number(highlighted(b.id, b.region))
|
||||
)
|
||||
);
|
||||
let hoveredPoint = $derived(culturalMap.find((point) => point.id === hovered));
|
||||
|
||||
type Box = { x: number; y: number; w: number; h: number };
|
||||
type Label = { x: number; y: number; anchor: 'start' | 'end' | 'middle' };
|
||||
const overlaps = (a: Box, b: Box) =>
|
||||
a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
|
||||
// Greedy placement: highlighted labels first, each tries right, left, above,
|
||||
// below. Background labels that fit nowhere are dropped (hover still names them).
|
||||
let labels = $derived.by(() => {
|
||||
const dots: Box[] = culturalMap.map((point) => ({
|
||||
x: sx(point.x) - 6,
|
||||
y: sy(point.y) - 6,
|
||||
w: 12,
|
||||
h: 12
|
||||
}));
|
||||
const placed: Box[] = [];
|
||||
const result: Record<string, Label> = {};
|
||||
const candidates = [...ordered].reverse().filter((point) => {
|
||||
return highlighted(point.id, point.region) || region === 'all';
|
||||
});
|
||||
for (const point of candidates) {
|
||||
const active = highlighted(point.id, point.region);
|
||||
const w = point.name.length * (active ? 7.4 : 6.4);
|
||||
const h = 13;
|
||||
const cx = sx(point.x);
|
||||
const cy = sy(point.y);
|
||||
const options: [Box, Label][] = [
|
||||
[
|
||||
{ x: cx + 8, y: cy - 7, w, h },
|
||||
{ x: cx + 8, y: cy + 4, anchor: 'start' }
|
||||
],
|
||||
[
|
||||
{ x: cx - 8 - w, y: cy - 7, w, h },
|
||||
{ x: cx - 8, y: cy + 4, anchor: 'end' }
|
||||
],
|
||||
[
|
||||
{ x: cx - w / 2, y: cy - 22, w, h },
|
||||
{ x: cx, y: cy - 11, anchor: 'middle' }
|
||||
],
|
||||
[
|
||||
{ x: cx - w / 2, y: cy + 8, w, h },
|
||||
{ x: cx, y: cy + 19, anchor: 'middle' }
|
||||
]
|
||||
];
|
||||
const fits = options.find(
|
||||
([box]) =>
|
||||
box.x >= 0 &&
|
||||
box.x + box.w <= width &&
|
||||
![...placed, ...dots].some((other) => overlaps(box, other))
|
||||
);
|
||||
const choice = fits ?? (active ? options[0] : undefined);
|
||||
if (!choice) continue;
|
||||
placed.push(choice[0]);
|
||||
result[point.id] = choice[1];
|
||||
}
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="wo-chart">
|
||||
<div class="mb-4 flex flex-wrap gap-2" role="group" aria-label="Highlight a region">
|
||||
<button class="wo-chip" aria-pressed={region === 'all'} onclick={() => (region = 'all')}>
|
||||
all
|
||||
</button>
|
||||
{#each usedRegions as id (id)}
|
||||
<button class="wo-chip" aria-pressed={region === id} onclick={() => (region = id)}>
|
||||
{regions[id].toLowerCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
class="w-full"
|
||||
role="img"
|
||||
aria-label="Cultural map: countries placed by traditional versus secular values and survival versus self-expression values"
|
||||
>
|
||||
{#each ticks as tick (tick)}
|
||||
<line class="wo-grid" x1={sx(tick)} x2={sx(tick)} y1={pad.top} y2={height - pad.bottom} />
|
||||
<line class="wo-grid" x1={pad.left} x2={width - pad.right} y1={sy(tick)} y2={sy(tick)} />
|
||||
{/each}
|
||||
<line class="wo-axis" x1={sx(0)} x2={sx(0)} y1={pad.top} y2={height - pad.bottom} />
|
||||
<line class="wo-axis" x1={pad.left} x2={width - pad.right} y1={sy(0)} y2={sy(0)} />
|
||||
|
||||
<text class="wo-axis-label" x={pad.left} y={height - 12}>← survival</text>
|
||||
<text class="wo-axis-label" x={width - pad.right} y={height - 12} text-anchor="end">
|
||||
self-expression →
|
||||
</text>
|
||||
<text
|
||||
class="wo-axis-label"
|
||||
x={14}
|
||||
y={height - pad.bottom}
|
||||
transform={`rotate(-90 14 ${height - pad.bottom})`}
|
||||
>
|
||||
← traditional
|
||||
</text>
|
||||
<text
|
||||
class="wo-axis-label"
|
||||
x={14}
|
||||
y={pad.top}
|
||||
text-anchor="end"
|
||||
transform={`rotate(-90 14 ${pad.top})`}
|
||||
>
|
||||
secular-rational →
|
||||
</text>
|
||||
|
||||
{#each ordered as point (point.id)}
|
||||
{@const active = highlighted(point.id, point.region)}
|
||||
{@const label = labels[point.id]}
|
||||
<g
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={`${point.name}: ${regions[point.region]}`}
|
||||
class="cursor-pointer outline-none"
|
||||
onmouseenter={() => (hovered = point.id)}
|
||||
onmouseleave={() => (hovered = null)}
|
||||
onfocus={() => (hovered = point.id)}
|
||||
onblur={() => (hovered = null)}
|
||||
onclick={() => onselect?.(point.id)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onselect?.(point.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<circle cx={sx(point.x)} cy={sy(point.y)} r="12" fill="transparent" />
|
||||
<circle
|
||||
cx={sx(point.x)}
|
||||
cy={sy(point.y)}
|
||||
r={point.id === selected ? 7 : 5}
|
||||
class={active ? 'wo-dot-active' : region === 'all' ? 'wo-dot' : 'wo-dot-dim'}
|
||||
/>
|
||||
{#if label}
|
||||
<text
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
text-anchor={label.anchor}
|
||||
class={active ? 'wo-label-strong' : 'wo-label'}>{point.name}</text
|
||||
>
|
||||
{/if}
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
{#if hoveredPoint}
|
||||
<div
|
||||
class="wo-tooltip"
|
||||
style:left={`${(sx(hoveredPoint.x) / width) * 100}%`}
|
||||
style:top={`${(sy(hoveredPoint.y) / height) * 100}%`}
|
||||
>
|
||||
<strong>{hoveredPoint.name}</strong>
|
||||
<span>{regions[hoveredPoint.region]}</span>
|
||||
<span
|
||||
>{hoveredPoint.y >= 0 ? 'secular' : 'traditional'}
|
||||
{Math.abs(hoveredPoint.y).toFixed(1)} ·
|
||||
{hoveredPoint.x >= 0 ? 'self-expression' : 'survival'}
|
||||
{Math.abs(hoveredPoint.x).toFixed(1)}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="wo-link mt-2" onclick={() => (showTable = !showTable)}>
|
||||
{showTable ? 'hide table' : 'show as table'}
|
||||
</button>
|
||||
{#if showTable}
|
||||
<table class="wo-table mt-3">
|
||||
<thead>
|
||||
<tr
|
||||
><th>country</th><th>region</th><th>traditional ↔ secular</th><th
|
||||
>survival ↔ self-expression</th
|
||||
></tr
|
||||
>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each [...culturalMap].sort((a, b) => a.name.localeCompare(b.name)) as point (point.id)}
|
||||
<tr>
|
||||
<td>{point.name}</td><td>{regions[point.region]}</td><td>{point.y.toFixed(1)}</td><td
|
||||
>{point.x.toFixed(1)}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
200
src/lib/world-opinions/PartyCompass.svelte
Normal file
200
src/lib/world-opinions/PartyCompass.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { countryById, parties, partyCountries } from './data';
|
||||
|
||||
let { focus = null }: { focus?: string | null } = $props();
|
||||
|
||||
const MAX = 3;
|
||||
const width = 640;
|
||||
const height = 460;
|
||||
const pad = { top: 24, right: 24, bottom: 44, left: 48 };
|
||||
const sx = (value: number) => pad.left + (value / 10) * (width - pad.left - pad.right);
|
||||
const sy = (value: number) => pad.top + (value / 10) * (height - pad.top - pad.bottom);
|
||||
const ticks = [0, 2.5, 5, 7.5, 10];
|
||||
|
||||
// Each selected country owns a color slot until it is removed, so toggling one
|
||||
// country never repaints the others.
|
||||
let slots = $state<(string | null)[]>(['us', 'nl', null]);
|
||||
let hovered = $state<string | null>(null);
|
||||
let showTable = $state(false);
|
||||
|
||||
const slotOf = (country: string) => slots.indexOf(country);
|
||||
const countryName = (id: string) => countryById.get(id)?.name ?? id;
|
||||
const key = (party: { country: string; short: string }) => `${party.country}-${party.short}`;
|
||||
|
||||
function toggle(id: string) {
|
||||
const index = slotOf(id);
|
||||
if (index >= 0) {
|
||||
slots[index] = null;
|
||||
return;
|
||||
}
|
||||
const free = slots.indexOf(null);
|
||||
if (free >= 0) slots[free] = id;
|
||||
}
|
||||
|
||||
// A country picked elsewhere on the page joins the comparison, taking the
|
||||
// last slot if all three are in use.
|
||||
$effect(() => {
|
||||
if (!focus || !partyCountries.includes(focus) || slots.includes(focus)) return;
|
||||
const free = slots.indexOf(null);
|
||||
slots[free >= 0 ? free : slots.length - 1] = focus;
|
||||
});
|
||||
|
||||
let visible = $derived(parties.filter((party) => slotOf(party.country) >= 0));
|
||||
// A party sitting on top of an earlier one gets nudged and labelled on the left.
|
||||
let crowded = $derived(
|
||||
new Set(
|
||||
visible
|
||||
.filter((party, index) =>
|
||||
visible
|
||||
.slice(0, index)
|
||||
.some(
|
||||
(other) =>
|
||||
Math.abs(other.econ - party.econ) < 0.4 &&
|
||||
Math.abs(other.social - party.social) < 0.4
|
||||
)
|
||||
)
|
||||
.map(key)
|
||||
)
|
||||
);
|
||||
let hoveredParty = $derived(visible.find((party) => key(party) === hovered));
|
||||
let full = $derived(!slots.includes(null));
|
||||
</script>
|
||||
|
||||
<div class="wo-chart">
|
||||
<div class="mb-2 flex flex-wrap gap-2" role="group" aria-label={`Compare up to ${MAX} countries`}>
|
||||
{#each partyCountries as id (id)}
|
||||
{@const slot = slotOf(id)}
|
||||
<button
|
||||
class="wo-chip"
|
||||
aria-pressed={slot >= 0}
|
||||
disabled={slot < 0 && full}
|
||||
onclick={() => toggle(id)}
|
||||
>
|
||||
{#if slot >= 0}<span class="wo-swatch" style:background={`var(--wo-s${slot + 1})`}
|
||||
></span>{/if}
|
||||
{countryName(id).toLowerCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-wrap gap-4 text-sm" aria-label="Legend">
|
||||
{#each slots as id, index (index)}
|
||||
{#if id}
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="wo-swatch" style:background={`var(--wo-s${index + 1})`}></span>{countryName(
|
||||
id
|
||||
)}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
class="w-full"
|
||||
role="img"
|
||||
aria-label="Party compass: economic left to right on the horizontal axis, progressive to traditional on the vertical axis"
|
||||
>
|
||||
{#each ticks as tick (tick)}
|
||||
<line class="wo-grid" x1={sx(tick)} x2={sx(tick)} y1={pad.top} y2={height - pad.bottom} />
|
||||
<line class="wo-grid" x1={pad.left} x2={width - pad.right} y1={sy(tick)} y2={sy(tick)} />
|
||||
{/each}
|
||||
<line class="wo-axis" x1={sx(5)} x2={sx(5)} y1={pad.top} y2={height - pad.bottom} />
|
||||
<line class="wo-axis" x1={pad.left} x2={width - pad.right} y1={sy(5)} y2={sy(5)} />
|
||||
|
||||
<text class="wo-axis-label" x={pad.left} y={height - 12}>← economic left</text>
|
||||
<text class="wo-axis-label" x={width - pad.right} y={height - 12} text-anchor="end">
|
||||
economic right →
|
||||
</text>
|
||||
<text
|
||||
class="wo-axis-label"
|
||||
x={14}
|
||||
y={height - pad.bottom}
|
||||
transform={`rotate(-90 14 ${height - pad.bottom})`}
|
||||
>
|
||||
traditional / authoritarian
|
||||
</text>
|
||||
<text
|
||||
class="wo-axis-label"
|
||||
x={14}
|
||||
y={pad.top}
|
||||
text-anchor="end"
|
||||
transform={`rotate(-90 14 ${pad.top})`}
|
||||
>
|
||||
progressive / libertarian
|
||||
</text>
|
||||
|
||||
{#each visible as party (key(party))}
|
||||
{@const color = `var(--wo-s${slotOf(party.country) + 1})`}
|
||||
<g
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={`${party.name} (${countryName(party.country)})`}
|
||||
class="cursor-pointer outline-none"
|
||||
onmouseenter={() => (hovered = key(party))}
|
||||
onmouseleave={() => (hovered = null)}
|
||||
onfocus={() => (hovered = key(party))}
|
||||
onblur={() => (hovered = null)}
|
||||
onclick={() => (hovered = key(party))}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') hovered = null;
|
||||
}}
|
||||
>
|
||||
<circle cx={sx(party.econ)} cy={sy(party.social)} r="12" fill="transparent" />
|
||||
<circle
|
||||
cx={sx(party.econ) + (crowded.has(key(party)) ? -5 : 0)}
|
||||
cy={sy(party.social)}
|
||||
r={hovered === key(party) ? 8 : 6}
|
||||
fill={color}
|
||||
class="wo-ring"
|
||||
/>
|
||||
<text
|
||||
x={sx(party.econ) + (crowded.has(key(party)) ? -14 : 9)}
|
||||
y={sy(party.social) + 4}
|
||||
text-anchor={crowded.has(key(party)) ? 'end' : 'start'}
|
||||
class="wo-label">{party.short}</text
|
||||
>
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
{#if hoveredParty}
|
||||
<div
|
||||
class="wo-tooltip"
|
||||
style:left={`${(sx(hoveredParty.econ) / width) * 100}%`}
|
||||
style:top={`${(sy(hoveredParty.social) / height) * 100}%`}
|
||||
>
|
||||
<strong>{hoveredParty.name}</strong>
|
||||
<span>{countryName(hoveredParty.country)}</span>
|
||||
<span
|
||||
>economic {hoveredParty.econ.toFixed(1)} · social {hoveredParty.social.toFixed(1)}</span
|
||||
>
|
||||
{#if hoveredParty.note}<span class="mt-1 max-w-64">{hoveredParty.note}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="wo-link mt-2" onclick={() => (showTable = !showTable)}>
|
||||
{showTable ? 'hide table' : 'show as table'}
|
||||
</button>
|
||||
{#if showTable}
|
||||
<table class="wo-table mt-3">
|
||||
<thead>
|
||||
<tr
|
||||
><th>party</th><th>country</th><th>economic (0 left, 10 right)</th><th
|
||||
>social (0 progressive, 10 traditional)</th
|
||||
></tr
|
||||
>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each visible as party (key(party))}
|
||||
<tr>
|
||||
<td>{party.name} ({party.short})</td><td>{countryName(party.country)}</td><td
|
||||
>{party.econ.toFixed(1)}</td
|
||||
><td>{party.social.toFixed(1)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
174
src/lib/world-opinions/WorldMap.svelte
Normal file
174
src/lib/world-opinions/WorldMap.svelte
Normal file
@@ -0,0 +1,174 @@
|
||||
<script lang="ts" module>
|
||||
import { geoEqualEarth, geoPath } from 'd3-geo';
|
||||
import { feature } from 'topojson-client';
|
||||
import type { Feature, FeatureCollection, Geometry } from 'geojson';
|
||||
import type { GeometryCollection, Topology } from 'topojson-specification';
|
||||
import world from 'world-atlas/countries-110m.json';
|
||||
|
||||
export type MapMode = 'tradition' | 'expression' | 'liberal' | 'divide';
|
||||
|
||||
const WIDTH = 960;
|
||||
const HEIGHT = 470;
|
||||
const topology = world as unknown as Topology<{
|
||||
countries: GeometryCollection<{ name: string }>;
|
||||
}>;
|
||||
const collection = feature(topology, topology.objects.countries) as FeatureCollection<
|
||||
Geometry,
|
||||
{ name: string }
|
||||
>;
|
||||
// Antarctica only adds a white band along the bottom.
|
||||
const land = collection.features.filter((shape) => shape.id !== '010');
|
||||
const projection = geoEqualEarth().fitSize([WIDTH, HEIGHT], {
|
||||
type: 'FeatureCollection',
|
||||
features: land
|
||||
});
|
||||
const path = geoPath(projection);
|
||||
const shapes = land.map((shape: Feature<Geometry, { name: string }>) => ({
|
||||
iso: String(shape.id),
|
||||
name: shape.properties.name,
|
||||
d: path(shape) ?? ''
|
||||
}));
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { countries, divides, liberalMeanings, type Country } from './data';
|
||||
|
||||
let {
|
||||
mode,
|
||||
selected = null,
|
||||
onselect
|
||||
}: { mode: MapMode; selected?: string | null; onselect?: (id: string) => void } = $props();
|
||||
|
||||
const byIso = new Map(countries.map((country) => [country.iso, country]));
|
||||
const categoryColor: Record<string, string> = {
|
||||
left: 'var(--wo-s1)',
|
||||
market: 'var(--wo-s2)',
|
||||
western: 'var(--wo-s3)',
|
||||
economy: 'var(--wo-s1)',
|
||||
culture: 'var(--wo-s2)',
|
||||
history: 'var(--wo-s3)',
|
||||
none: 'var(--wo-none)'
|
||||
};
|
||||
|
||||
function score(country: Country | undefined) {
|
||||
if (!country?.values) return undefined;
|
||||
return mode === 'tradition' ? country.values.y : country.values.x;
|
||||
}
|
||||
|
||||
function fill(country: Country | undefined) {
|
||||
if (mode === 'liberal')
|
||||
return country?.liberal ? categoryColor[country.liberal] : 'var(--wo-nodata)';
|
||||
if (mode === 'divide')
|
||||
return country?.divide ? categoryColor[country.divide] : 'var(--wo-nodata)';
|
||||
const value = score(country);
|
||||
if (value === undefined) return 'var(--wo-nodata)';
|
||||
const strength = Math.round(Math.min(Math.abs(value) / 2, 1) * 100);
|
||||
const pole = value >= 0 ? 'var(--wo-pos)' : 'var(--wo-neg)';
|
||||
return `color-mix(in oklab, ${pole} ${strength}%, var(--wo-mid))`;
|
||||
}
|
||||
|
||||
const poles: Record<'tradition' | 'expression', [string, string]> = {
|
||||
tradition: ['traditional', 'secular'],
|
||||
expression: ['survival', 'self-expression']
|
||||
};
|
||||
|
||||
function describe(country: Country) {
|
||||
if (mode === 'liberal')
|
||||
return country.liberal ? `"liberal" = ${liberalMeanings[country.liberal]}` : 'no data';
|
||||
if (mode === 'divide') return country.divide ? divides[country.divide] : 'no data';
|
||||
const value = score(country);
|
||||
if (value === undefined) return 'not surveyed';
|
||||
const [negative, positive] = poles[mode];
|
||||
const strength = Math.abs(value);
|
||||
const word =
|
||||
strength < 0.3 ? 'middle' : strength < 1 ? 'somewhat' : strength < 1.7 ? 'quite' : 'very';
|
||||
return word === 'middle' ? 'in the middle' : `${word} ${value >= 0 ? positive : negative}`;
|
||||
}
|
||||
|
||||
let hovered = $state<{ iso: string; name: string; x: number; y: number } | null>(null);
|
||||
let hoveredCountry = $derived(hovered ? byIso.get(hovered.iso) : undefined);
|
||||
let selectedShape = $derived(
|
||||
shapes.find((shape) => shape.iso === countries.find((c) => c.id === selected)?.iso)
|
||||
);
|
||||
|
||||
function onmove(event: PointerEvent) {
|
||||
const target = event.target as SVGElement;
|
||||
const iso = target.dataset?.iso;
|
||||
if (!iso) {
|
||||
hovered = null;
|
||||
return;
|
||||
}
|
||||
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
hovered = {
|
||||
iso,
|
||||
name: target.dataset.name ?? '',
|
||||
x: event.clientX - box.left,
|
||||
y: event.clientY - box.top
|
||||
};
|
||||
}
|
||||
|
||||
function onclick(event: MouseEvent) {
|
||||
const iso = (event.target as SVGElement).dataset?.iso;
|
||||
const country = iso ? byIso.get(iso) : undefined;
|
||||
if (country) onselect?.(country.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Pointer-only shortcut; the country picker next to the map is the keyboard path. -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="relative" onpointermove={onmove} onpointerleave={() => (hovered = null)} {onclick}>
|
||||
<svg
|
||||
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
||||
class="w-full"
|
||||
role="img"
|
||||
aria-label="World map colored by the selected layer"
|
||||
>
|
||||
{#each shapes as shape (shape.iso + shape.name)}
|
||||
{@const country = byIso.get(shape.iso)}
|
||||
<path
|
||||
d={shape.d}
|
||||
data-iso={shape.iso}
|
||||
data-name={country?.name ?? shape.name}
|
||||
fill={fill(country)}
|
||||
class={country ? 'wo-country cursor-pointer' : 'wo-country'}
|
||||
class:wo-country-hover={hovered?.iso === shape.iso}
|
||||
/>
|
||||
{/each}
|
||||
{#if selectedShape}
|
||||
<path d={selectedShape.d} class="wo-country-selected" />
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
{#if hovered}
|
||||
<div class="wo-tooltip" style:left={`${hovered.x}px`} style:top={`${hovered.y}px`}>
|
||||
<strong>{hovered.name}</strong>
|
||||
{#if hoveredCountry}
|
||||
<span>{describe(hoveredCountry)}</span>
|
||||
{#if hoveredCountry.note && (mode === 'liberal' || mode === 'divide')}
|
||||
<span class="wo-tooltip-note">{hoveredCountry.note}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span>no data</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-[var(--site-muted)]">
|
||||
{#if mode === 'tradition' || mode === 'expression'}
|
||||
<span class="flex items-center gap-2">
|
||||
{poles[mode][0]}
|
||||
<span class="wo-gradient" aria-hidden="true"></span>
|
||||
{poles[mode][1]}
|
||||
</span>
|
||||
{:else}
|
||||
{#each Object.entries(mode === 'liberal' ? liberalMeanings : divides) as [key, label] (key)}
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="wo-swatch" style:background={categoryColor[key]}></span>{label}
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="wo-swatch wo-swatch-nodata"></span>no data
|
||||
</span>
|
||||
</div>
|
||||
44
src/lib/world-opinions/data.test.ts
Normal file
44
src/lib/world-opinions/data.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import world from 'world-atlas/countries-110m.json';
|
||||
import { countries, countryById, narratives, parties, partyCountries, profiles } from './data';
|
||||
|
||||
describe('world opinions data', () => {
|
||||
it('has unique country ids and ISO codes', () => {
|
||||
expect(new Set(countries.map((country) => country.id)).size).toBe(countries.length);
|
||||
expect(new Set(countries.map((country) => country.iso)).size).toBe(countries.length);
|
||||
});
|
||||
|
||||
it('matches every country to a shape on the map', () => {
|
||||
const shapes = new Set(
|
||||
world.objects.countries.geometries.map((shape: { id?: string }) => String(shape.id))
|
||||
);
|
||||
for (const country of countries) expect(shapes.has(country.iso), country.name).toBe(true);
|
||||
});
|
||||
|
||||
it('only references known countries', () => {
|
||||
const referenced = [
|
||||
...profiles.map((profile) => profile.id),
|
||||
...partyCountries,
|
||||
...parties.map((party) => party.country),
|
||||
...narratives.flatMap((narrative) => narrative.views.map((view) => view.country))
|
||||
];
|
||||
for (const id of referenced) expect(countryById.has(id), id).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps party positions on the 0–10 scale with unique keys', () => {
|
||||
const keys = new Set(parties.map((party) => `${party.country}-${party.short}`));
|
||||
expect(keys.size).toBe(parties.length);
|
||||
for (const party of parties) {
|
||||
for (const value of [party.econ, party.social]) {
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThanOrEqual(10);
|
||||
}
|
||||
}
|
||||
for (const id of partyCountries) {
|
||||
expect(
|
||||
parties.some((party) => party.country === id),
|
||||
id
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
976
src/lib/world-opinions/data.ts
Normal file
976
src/lib/world-opinions/data.ts
Normal file
@@ -0,0 +1,976 @@
|
||||
// World Opinions: a hand-curated orientation guide, not a dataset of record.
|
||||
// Positions are approximate and meant for comparing countries/parties relative
|
||||
// to each other. Sources are listed in `sources` at the bottom of this file.
|
||||
|
||||
export type RegionId =
|
||||
| 'north-america'
|
||||
| 'western-europe'
|
||||
| 'nordics'
|
||||
| 'eastern-europe'
|
||||
| 'east-asia'
|
||||
| 'south-asia'
|
||||
| 'southeast-asia'
|
||||
| 'latin-america'
|
||||
| 'middle-east'
|
||||
| 'africa'
|
||||
| 'oceania';
|
||||
|
||||
export const regions: Record<RegionId, string> = {
|
||||
'north-america': 'North America',
|
||||
'western-europe': 'Western Europe',
|
||||
nordics: 'Nordics',
|
||||
'eastern-europe': 'Central & Eastern Europe',
|
||||
'east-asia': 'East Asia',
|
||||
'south-asia': 'South Asia',
|
||||
'southeast-asia': 'Southeast Asia',
|
||||
'latin-america': 'Latin America',
|
||||
'middle-east': 'Middle East & North Africa',
|
||||
africa: 'Sub-Saharan Africa',
|
||||
oceania: 'Oceania'
|
||||
};
|
||||
|
||||
// What a local means by "liberal".
|
||||
export type LiberalMeaning = 'left' | 'market' | 'western';
|
||||
export const liberalMeanings: Record<LiberalMeaning, string> = {
|
||||
left: 'left-leaning, progressive',
|
||||
market: 'free-market, often center-right',
|
||||
western: 'pro-Western opposition to nationalists'
|
||||
};
|
||||
|
||||
// What the main political divide is mostly about.
|
||||
export type Divide = 'economy' | 'culture' | 'history' | 'none';
|
||||
export const divides: Record<Divide, string> = {
|
||||
economy: 'economy (taxes, welfare, unions)',
|
||||
culture: 'culture (religion, identity, values)',
|
||||
history: 'history & geopolitics',
|
||||
none: 'no open party competition'
|
||||
};
|
||||
|
||||
export type Country = {
|
||||
id: string;
|
||||
/** ISO 3166-1 numeric, as used by world-atlas */
|
||||
iso: string;
|
||||
name: string;
|
||||
region: RegionId;
|
||||
/**
|
||||
* Inglehart–Welzel position. x: survival (-) ↔ self-expression (+);
|
||||
* y: traditional (-) ↔ secular-rational (+). Undefined = not surveyed.
|
||||
*/
|
||||
values?: { x: number; y: number };
|
||||
liberal?: LiberalMeaning;
|
||||
divide?: Divide;
|
||||
/** One line shown on hover, only where the label alone would mislead. */
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export const countries: Country[] = [
|
||||
{
|
||||
id: 'se',
|
||||
iso: '752',
|
||||
name: 'Sweden',
|
||||
region: 'nordics',
|
||||
values: { x: 2.4, y: 1.8 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'dk',
|
||||
iso: '208',
|
||||
name: 'Denmark',
|
||||
region: 'nordics',
|
||||
values: { x: 2.3, y: 1.2 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: '"Venstre" (literally "Left") is the liberal, center-right party'
|
||||
},
|
||||
{
|
||||
id: 'no',
|
||||
iso: '578',
|
||||
name: 'Norway',
|
||||
region: 'nordics',
|
||||
values: { x: 2.1, y: 1.4 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'fi',
|
||||
iso: '246',
|
||||
name: 'Finland',
|
||||
region: 'nordics',
|
||||
values: { x: 1.6, y: 1.0 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'nl',
|
||||
iso: '528',
|
||||
name: 'Netherlands',
|
||||
region: 'western-europe',
|
||||
values: { x: 2.0, y: 1.1 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: 'Liberal VVD is the main center-right party'
|
||||
},
|
||||
{
|
||||
id: 'be',
|
||||
iso: '056',
|
||||
name: 'Belgium',
|
||||
region: 'western-europe',
|
||||
values: { x: 1.4, y: 0.8 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: 'Language (Flemish vs French-speaking) splits every party family'
|
||||
},
|
||||
{
|
||||
id: 'de',
|
||||
iso: '276',
|
||||
name: 'Germany',
|
||||
region: 'western-europe',
|
||||
values: { x: 1.3, y: 1.4 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: 'Liberal FDP = pro-market, low taxes'
|
||||
},
|
||||
{
|
||||
id: 'at',
|
||||
iso: '040',
|
||||
name: 'Austria',
|
||||
region: 'western-europe',
|
||||
values: { x: 1.3, y: 0.6 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'ch',
|
||||
iso: '756',
|
||||
name: 'Switzerland',
|
||||
region: 'western-europe',
|
||||
values: { x: 1.9, y: 0.7 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'gb',
|
||||
iso: '826',
|
||||
name: 'United Kingdom',
|
||||
region: 'western-europe',
|
||||
values: { x: 1.8, y: 0.3 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: 'Liberal Democrats sit in the center; Brexit cut across old lines'
|
||||
},
|
||||
{
|
||||
id: 'ie',
|
||||
iso: '372',
|
||||
name: 'Ireland',
|
||||
region: 'western-europe',
|
||||
values: { x: 1.4, y: -0.4 },
|
||||
liberal: 'market',
|
||||
divide: 'history',
|
||||
note: 'The two big parties descend from opposite sides of the 1922 civil war'
|
||||
},
|
||||
{
|
||||
id: 'fr',
|
||||
iso: '250',
|
||||
name: 'France',
|
||||
region: 'western-europe',
|
||||
values: { x: 1.1, y: 0.8 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: '"Libéral" means pro-market, often used as a criticism'
|
||||
},
|
||||
{
|
||||
id: 'es',
|
||||
iso: '724',
|
||||
name: 'Spain',
|
||||
region: 'western-europe',
|
||||
values: { x: 1.3, y: 0.3 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: 'Regional nationalism (Catalonia, Basque Country) is a second axis'
|
||||
},
|
||||
{
|
||||
id: 'pt',
|
||||
iso: '620',
|
||||
name: 'Portugal',
|
||||
region: 'western-europe',
|
||||
values: { x: 0.8, y: -0.3 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'it',
|
||||
iso: '380',
|
||||
name: 'Italy',
|
||||
region: 'western-europe',
|
||||
values: { x: 0.5, y: 0.0 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'gr',
|
||||
iso: '300',
|
||||
name: 'Greece',
|
||||
region: 'eastern-europe',
|
||||
values: { x: 0.2, y: 0.0 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'us',
|
||||
iso: '840',
|
||||
name: 'United States',
|
||||
region: 'north-america',
|
||||
values: { x: 1.3, y: -0.5 },
|
||||
liberal: 'left',
|
||||
divide: 'culture',
|
||||
note: '"Liberal" = Democrat; the Democrats sit near Europe\'s center economically'
|
||||
},
|
||||
{
|
||||
id: 'ca',
|
||||
iso: '124',
|
||||
name: 'Canada',
|
||||
region: 'north-america',
|
||||
values: { x: 1.8, y: 0.0 },
|
||||
liberal: 'left',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'mx',
|
||||
iso: '484',
|
||||
name: 'Mexico',
|
||||
region: 'latin-america',
|
||||
values: { x: 0.6, y: -1.3 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'br',
|
||||
iso: '076',
|
||||
name: 'Brazil',
|
||||
region: 'latin-america',
|
||||
values: { x: 0.1, y: -1.0 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: 'Bolsonaro\'s party is literally called "Partido Liberal"'
|
||||
},
|
||||
{
|
||||
id: 'ar',
|
||||
iso: '032',
|
||||
name: 'Argentina',
|
||||
region: 'latin-america',
|
||||
values: { x: 0.7, y: -0.6 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: 'President Milei calls himself a "liberal-libertarian"'
|
||||
},
|
||||
{
|
||||
id: 'cl',
|
||||
iso: '152',
|
||||
name: 'Chile',
|
||||
region: 'latin-america',
|
||||
values: { x: 0.8, y: -0.3 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'co',
|
||||
iso: '170',
|
||||
name: 'Colombia',
|
||||
region: 'latin-america',
|
||||
values: { x: 0.5, y: -1.6 },
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'pe',
|
||||
iso: '604',
|
||||
name: 'Peru',
|
||||
region: 'latin-america',
|
||||
values: { x: 0.3, y: -1.1 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 've',
|
||||
iso: '862',
|
||||
name: 'Venezuela',
|
||||
region: 'latin-america',
|
||||
values: { x: 0.4, y: -1.5 },
|
||||
divide: 'none'
|
||||
},
|
||||
{
|
||||
id: 'au',
|
||||
iso: '036',
|
||||
name: 'Australia',
|
||||
region: 'oceania',
|
||||
values: { x: 2.0, y: 0.2 },
|
||||
liberal: 'market',
|
||||
divide: 'economy',
|
||||
note: 'The Liberal Party is the main conservative party'
|
||||
},
|
||||
{
|
||||
id: 'nz',
|
||||
iso: '554',
|
||||
name: 'New Zealand',
|
||||
region: 'oceania',
|
||||
values: { x: 2.2, y: 0.1 },
|
||||
liberal: 'market',
|
||||
divide: 'economy'
|
||||
},
|
||||
{
|
||||
id: 'pl',
|
||||
iso: '616',
|
||||
name: 'Poland',
|
||||
region: 'eastern-europe',
|
||||
values: { x: 0.2, y: -0.6 },
|
||||
liberal: 'western',
|
||||
divide: 'culture'
|
||||
},
|
||||
{
|
||||
id: 'cz',
|
||||
iso: '203',
|
||||
name: 'Czechia',
|
||||
region: 'eastern-europe',
|
||||
values: { x: 0.5, y: 1.2 },
|
||||
liberal: 'western',
|
||||
divide: 'culture'
|
||||
},
|
||||
{
|
||||
id: 'hu',
|
||||
iso: '348',
|
||||
name: 'Hungary',
|
||||
region: 'eastern-europe',
|
||||
values: { x: -0.4, y: 0.4 },
|
||||
liberal: 'western',
|
||||
divide: 'culture',
|
||||
note: 'Orbán calls his model "illiberal democracy"'
|
||||
},
|
||||
{
|
||||
id: 'ro',
|
||||
iso: '642',
|
||||
name: 'Romania',
|
||||
region: 'eastern-europe',
|
||||
values: { x: -0.4, y: -0.6 },
|
||||
liberal: 'western',
|
||||
divide: 'culture'
|
||||
},
|
||||
{
|
||||
id: 'rs',
|
||||
iso: '688',
|
||||
name: 'Serbia',
|
||||
region: 'eastern-europe',
|
||||
values: { x: -0.6, y: 0.1 },
|
||||
liberal: 'western',
|
||||
divide: 'history'
|
||||
},
|
||||
{
|
||||
id: 'ua',
|
||||
iso: '804',
|
||||
name: 'Ukraine',
|
||||
region: 'eastern-europe',
|
||||
values: { x: -0.9, y: 0.2 },
|
||||
divide: 'history',
|
||||
note: 'Politics long split between pro-Western and pro-Russian orientation'
|
||||
},
|
||||
{
|
||||
id: 'ru',
|
||||
iso: '643',
|
||||
name: 'Russia',
|
||||
region: 'eastern-europe',
|
||||
values: { x: -1.0, y: 0.6 },
|
||||
liberal: 'western',
|
||||
divide: 'none',
|
||||
note: '"Liberal" = the pro-Western opposition, mostly in exile or prison'
|
||||
},
|
||||
{
|
||||
id: 'kz',
|
||||
iso: '398',
|
||||
name: 'Kazakhstan',
|
||||
region: 'eastern-europe',
|
||||
values: { x: -0.6, y: 0.4 },
|
||||
divide: 'none'
|
||||
},
|
||||
{
|
||||
id: 'tr',
|
||||
iso: '792',
|
||||
name: 'Turkey',
|
||||
region: 'middle-east',
|
||||
values: { x: -0.6, y: -0.9 },
|
||||
liberal: 'western',
|
||||
divide: 'culture',
|
||||
note: 'Main divide: religious-conservative vs secular'
|
||||
},
|
||||
{
|
||||
id: 'ir',
|
||||
iso: '364',
|
||||
name: 'Iran',
|
||||
region: 'middle-east',
|
||||
values: { x: -0.9, y: -0.6 },
|
||||
liberal: 'western',
|
||||
divide: 'none'
|
||||
},
|
||||
{
|
||||
id: 'iq',
|
||||
iso: '368',
|
||||
name: 'Iraq',
|
||||
region: 'middle-east',
|
||||
values: { x: -1.2, y: -1.3 },
|
||||
divide: 'culture'
|
||||
},
|
||||
{
|
||||
id: 'eg',
|
||||
iso: '818',
|
||||
name: 'Egypt',
|
||||
region: 'middle-east',
|
||||
values: { x: -1.5, y: -1.5 },
|
||||
divide: 'none'
|
||||
},
|
||||
{ id: 'ma', iso: '504', name: 'Morocco', region: 'middle-east', values: { x: -1.2, y: -1.5 } },
|
||||
{ id: 'sa', iso: '682', name: 'Saudi Arabia', region: 'middle-east', divide: 'none' },
|
||||
{ id: 'ng', iso: '566', name: 'Nigeria', region: 'africa', values: { x: -0.3, y: -1.8 } },
|
||||
{ id: 'et', iso: '231', name: 'Ethiopia', region: 'africa', values: { x: -0.9, y: -1.5 } },
|
||||
{ id: 'ke', iso: '404', name: 'Kenya', region: 'africa', values: { x: -0.4, y: -1.5 } },
|
||||
{
|
||||
id: 'za',
|
||||
iso: '710',
|
||||
name: 'South Africa',
|
||||
region: 'africa',
|
||||
values: { x: 0.0, y: -1.2 },
|
||||
divide: 'history',
|
||||
note: 'Politics still shaped by apartheid and the liberation struggle'
|
||||
},
|
||||
{
|
||||
id: 'in',
|
||||
iso: '356',
|
||||
name: 'India',
|
||||
region: 'south-asia',
|
||||
values: { x: -0.5, y: -0.5 },
|
||||
divide: 'culture',
|
||||
note: 'Main divide: Hindu nationalism vs secular pluralism'
|
||||
},
|
||||
{ id: 'pk', iso: '586', name: 'Pakistan', region: 'south-asia', values: { x: -1.0, y: -1.4 } },
|
||||
{
|
||||
id: 'bd',
|
||||
iso: '050',
|
||||
name: 'Bangladesh',
|
||||
region: 'south-asia',
|
||||
values: { x: -1.0, y: -1.1 },
|
||||
divide: 'history'
|
||||
},
|
||||
{
|
||||
id: 'cn',
|
||||
iso: '156',
|
||||
name: 'China',
|
||||
region: 'east-asia',
|
||||
values: { x: -0.9, y: 1.5 },
|
||||
liberal: 'western',
|
||||
divide: 'none',
|
||||
note: '"Liberal" = pro-Western intellectuals, a label the state treats with suspicion'
|
||||
},
|
||||
{
|
||||
id: 'jp',
|
||||
iso: '392',
|
||||
name: 'Japan',
|
||||
region: 'east-asia',
|
||||
values: { x: 0.4, y: 1.8 },
|
||||
liberal: 'market',
|
||||
divide: 'history',
|
||||
note: 'The "Liberal Democratic Party" is the conservative party in power since 1955'
|
||||
},
|
||||
{
|
||||
id: 'kr',
|
||||
iso: '410',
|
||||
name: 'South Korea',
|
||||
region: 'east-asia',
|
||||
values: { x: -0.3, y: 1.2 },
|
||||
liberal: 'left',
|
||||
divide: 'history',
|
||||
note: 'Divide is mostly about North Korea, Japan and the US'
|
||||
},
|
||||
{
|
||||
id: 'tw',
|
||||
iso: '158',
|
||||
name: 'Taiwan',
|
||||
region: 'east-asia',
|
||||
values: { x: 0.0, y: 1.3 },
|
||||
divide: 'history',
|
||||
note: 'Main divide: how close to be with mainland China'
|
||||
},
|
||||
{
|
||||
id: 'vn',
|
||||
iso: '704',
|
||||
name: 'Vietnam',
|
||||
region: 'southeast-asia',
|
||||
values: { x: -0.6, y: 0.5 },
|
||||
divide: 'none'
|
||||
},
|
||||
{
|
||||
id: 'th',
|
||||
iso: '764',
|
||||
name: 'Thailand',
|
||||
region: 'southeast-asia',
|
||||
values: { x: 0.0, y: -0.4 },
|
||||
divide: 'history',
|
||||
note: 'Main divide: monarchy and military vs reformists'
|
||||
},
|
||||
{
|
||||
id: 'my',
|
||||
iso: '458',
|
||||
name: 'Malaysia',
|
||||
region: 'southeast-asia',
|
||||
values: { x: -0.6, y: -0.9 },
|
||||
divide: 'culture'
|
||||
},
|
||||
{
|
||||
id: 'ph',
|
||||
iso: '608',
|
||||
name: 'Philippines',
|
||||
region: 'southeast-asia',
|
||||
values: { x: -0.2, y: -1.4 }
|
||||
},
|
||||
{
|
||||
id: 'id',
|
||||
iso: '360',
|
||||
name: 'Indonesia',
|
||||
region: 'southeast-asia',
|
||||
values: { x: -0.8, y: -1.2 },
|
||||
divide: 'culture'
|
||||
}
|
||||
];
|
||||
|
||||
export const countryById = new Map(countries.map((country) => [country.id, country]));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Party compass: parties on one shared scale
|
||||
// econ: 0 = far left (state-led, redistributive) … 10 = far right (free market)
|
||||
// social: 0 = progressive/libertarian … 10 = traditional/authoritarian
|
||||
// Hand-placed approximations inspired by the Chapel Hill Expert Survey and
|
||||
// Manifesto Project; good for relative position, not for decimals.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Party = {
|
||||
country: string;
|
||||
short: string;
|
||||
name: string;
|
||||
econ: number;
|
||||
social: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export const partyCountries = [
|
||||
'us',
|
||||
'nl',
|
||||
'de',
|
||||
'gb',
|
||||
'fr',
|
||||
'se',
|
||||
'pl',
|
||||
'jp',
|
||||
'kr',
|
||||
'in',
|
||||
'br',
|
||||
'ca'
|
||||
];
|
||||
|
||||
export const parties: Party[] = [
|
||||
{
|
||||
country: 'us',
|
||||
short: 'Dem',
|
||||
name: 'Democratic Party',
|
||||
econ: 4.8,
|
||||
social: 3.5,
|
||||
note: 'The US "liberals": near Europe\'s center economically'
|
||||
},
|
||||
{ country: 'us', short: 'GOP', name: 'Republican Party', econ: 7.8, social: 8.0 },
|
||||
|
||||
{ country: 'nl', short: 'GL-PvdA', name: 'GroenLinks-PvdA', econ: 2.5, social: 1.5 },
|
||||
{ country: 'nl', short: 'SP', name: 'Socialistische Partij', econ: 1.5, social: 5.0 },
|
||||
{ country: 'nl', short: 'D66', name: 'Democraten 66', econ: 5.5, social: 1.5 },
|
||||
{ country: 'nl', short: 'CDA', name: 'Christen-Democratisch Appèl', econ: 6.0, social: 6.0 },
|
||||
{
|
||||
country: 'nl',
|
||||
short: 'VVD',
|
||||
name: 'Volkspartij voor Vrijheid en Democratie',
|
||||
econ: 7.5,
|
||||
social: 5.0,
|
||||
note: 'Dutch "liberals": market-liberal, center-right'
|
||||
},
|
||||
{ country: 'nl', short: 'BBB', name: 'BoerBurgerBeweging', econ: 6.0, social: 7.5 },
|
||||
{
|
||||
country: 'nl',
|
||||
short: 'PVV',
|
||||
name: 'Partij voor de Vrijheid',
|
||||
econ: 4.5,
|
||||
social: 9.0,
|
||||
note: 'Radical right on immigration, centrist on welfare'
|
||||
},
|
||||
|
||||
{ country: 'de', short: 'Linke', name: 'Die Linke', econ: 1.0, social: 2.5 },
|
||||
{ country: 'de', short: 'Grüne', name: 'Bündnis 90/Die Grünen', econ: 3.0, social: 1.5 },
|
||||
{ country: 'de', short: 'SPD', name: 'Sozialdemokratische Partei', econ: 3.5, social: 3.5 },
|
||||
{ country: 'de', short: 'BSW', name: 'Bündnis Sahra Wagenknecht', econ: 2.0, social: 7.0 },
|
||||
{
|
||||
country: 'de',
|
||||
short: 'FDP',
|
||||
name: 'Freie Demokratische Partei',
|
||||
econ: 8.5,
|
||||
social: 3.0,
|
||||
note: 'German "liberals": socially open, strongly pro-market'
|
||||
},
|
||||
{ country: 'de', short: 'CDU', name: 'CDU/CSU', econ: 6.5, social: 6.0 },
|
||||
{ country: 'de', short: 'AfD', name: 'Alternative für Deutschland', econ: 7.0, social: 9.5 },
|
||||
|
||||
{ country: 'gb', short: 'Green', name: 'Green Party', econ: 1.5, social: 1.5 },
|
||||
{ country: 'gb', short: 'Lab', name: 'Labour', econ: 3.8, social: 3.5 },
|
||||
{ country: 'gb', short: 'LD', name: 'Liberal Democrats', econ: 5.0, social: 2.5 },
|
||||
{ country: 'gb', short: 'Con', name: 'Conservatives', econ: 7.0, social: 6.5 },
|
||||
{ country: 'gb', short: 'Reform', name: 'Reform UK', econ: 7.5, social: 8.5 },
|
||||
|
||||
{ country: 'fr', short: 'LFI', name: 'La France Insoumise', econ: 1.0, social: 2.5 },
|
||||
{ country: 'fr', short: 'PS', name: 'Parti Socialiste', econ: 3.0, social: 3.0 },
|
||||
{ country: 'fr', short: 'RE', name: 'Renaissance', econ: 6.5, social: 4.0 },
|
||||
{ country: 'fr', short: 'LR', name: 'Les Républicains', econ: 7.5, social: 7.0 },
|
||||
{
|
||||
country: 'fr',
|
||||
short: 'RN',
|
||||
name: 'Rassemblement National',
|
||||
econ: 4.5,
|
||||
social: 9.0,
|
||||
note: 'Economically left of Macron: lower retirement age, protectionism'
|
||||
},
|
||||
|
||||
{ country: 'se', short: 'V', name: 'Vänsterpartiet', econ: 1.0, social: 2.0 },
|
||||
{ country: 'se', short: 'S', name: 'Socialdemokraterna', econ: 3.0, social: 3.5 },
|
||||
{ country: 'se', short: 'C', name: 'Centerpartiet', econ: 7.0, social: 2.5 },
|
||||
{ country: 'se', short: 'M', name: 'Moderaterna', econ: 7.5, social: 5.5 },
|
||||
{ country: 'se', short: 'SD', name: 'Sverigedemokraterna', econ: 5.5, social: 9.0 },
|
||||
|
||||
{ country: 'pl', short: 'Lewica', name: 'Lewica', econ: 2.0, social: 2.0 },
|
||||
{ country: 'pl', short: 'KO', name: 'Koalicja Obywatelska', econ: 6.5, social: 4.0 },
|
||||
{
|
||||
country: 'pl',
|
||||
short: 'PiS',
|
||||
name: 'Prawo i Sprawiedliwość',
|
||||
econ: 3.5,
|
||||
social: 9.0,
|
||||
note: 'Culturally right, economically left (big family benefits)'
|
||||
},
|
||||
{ country: 'pl', short: 'Konf', name: 'Konfederacja', econ: 9.0, social: 9.0 },
|
||||
|
||||
{ country: 'jp', short: 'JCP', name: 'Japanese Communist Party', econ: 1.0, social: 3.0 },
|
||||
{ country: 'jp', short: 'CDP', name: 'Constitutional Democratic Party', econ: 4.0, social: 3.5 },
|
||||
{ country: 'jp', short: 'Komeito', name: 'Komeito', econ: 5.0, social: 5.5 },
|
||||
{
|
||||
country: 'jp',
|
||||
short: 'LDP',
|
||||
name: 'Liberal Democratic Party',
|
||||
econ: 6.0,
|
||||
social: 7.0,
|
||||
note: 'Called "Liberal", governs as conservatives since 1955'
|
||||
},
|
||||
{ country: 'jp', short: 'Ishin', name: 'Nippon Ishin no Kai', econ: 7.5, social: 6.0 },
|
||||
{ country: 'jp', short: 'Sansei', name: 'Sanseitō', econ: 6.0, social: 9.0 },
|
||||
|
||||
{ country: 'kr', short: 'Justice', name: 'Justice Party', econ: 1.5, social: 1.5 },
|
||||
{
|
||||
country: 'kr',
|
||||
short: 'DPK',
|
||||
name: 'Democratic Party of Korea',
|
||||
econ: 4.0,
|
||||
social: 4.5,
|
||||
note: 'The "progressive" camp, cautious on social issues'
|
||||
},
|
||||
{ country: 'kr', short: 'PPP', name: 'People Power Party', econ: 7.0, social: 7.0 },
|
||||
|
||||
{ country: 'in', short: 'INC', name: 'Indian National Congress', econ: 4.5, social: 4.0 },
|
||||
{
|
||||
country: 'in',
|
||||
short: 'BJP',
|
||||
name: 'Bharatiya Janata Party',
|
||||
econ: 6.0,
|
||||
social: 8.5,
|
||||
note: 'Hindu-nationalist, pro-business, big welfare schemes'
|
||||
},
|
||||
|
||||
{ country: 'br', short: 'PT', name: 'Partido dos Trabalhadores', econ: 2.5, social: 4.5 },
|
||||
{ country: 'br', short: 'MDB', name: 'Movimento Democrático Brasileiro', econ: 6.0, social: 6.0 },
|
||||
{ country: 'br', short: 'PL', name: 'Partido Liberal', econ: 7.5, social: 9.0 },
|
||||
|
||||
{ country: 'ca', short: 'NDP', name: 'New Democratic Party', econ: 2.5, social: 2.5 },
|
||||
{ country: 'ca', short: 'LPC', name: 'Liberal Party of Canada', econ: 4.8, social: 3.0 },
|
||||
{ country: 'ca', short: 'CPC', name: 'Conservative Party of Canada', econ: 7.5, social: 7.0 }
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Country profiles, kept short enough to scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Profile = {
|
||||
id: string;
|
||||
/** What "normal" politics looks like, in one line. */
|
||||
center: string;
|
||||
fights: string[];
|
||||
timeline: { year: string; label: string }[];
|
||||
blindSpots: string[];
|
||||
};
|
||||
|
||||
export const profiles: Profile[] = [
|
||||
{
|
||||
id: 'us',
|
||||
center: 'Individual liberty and suspicion of government, on both sides',
|
||||
fights: ['abortion', 'guns', 'race', 'immigration', 'religion'],
|
||||
timeline: [
|
||||
{ year: '1776', label: 'revolt against a distant state' },
|
||||
{ year: '1861', label: 'civil war over slavery' },
|
||||
{ year: '1945', label: 'wins WWII, superpower' },
|
||||
{ year: '1964', label: 'Civil Rights Act' },
|
||||
{ year: '1991', label: 'wins the Cold War' }
|
||||
],
|
||||
blindSpots: [
|
||||
'assumes its left/right is universal',
|
||||
'partisan media bubbles',
|
||||
'little view of other systems'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'nl',
|
||||
center: 'Coalition consensus ("polder model") around a strong welfare state',
|
||||
fights: ['immigration', 'housing', 'nitrogen & farmers', 'EU'],
|
||||
timeline: [
|
||||
{ year: '1600s', label: 'Golden Age, trade empire' },
|
||||
{ year: '1940', label: 'German occupation' },
|
||||
{ year: '1949', label: 'loses Indonesia' },
|
||||
{ year: '1960s', label: 'pillars break, secularization' },
|
||||
{ year: '2002', label: 'Fortuyn, immigration debate' }
|
||||
],
|
||||
blindSpots: ['tolerance self-image vs discrimination', 'colonial past taught thinly']
|
||||
},
|
||||
{
|
||||
id: 'de',
|
||||
center: 'Social market economy, fiscal caution, "never again"',
|
||||
fights: ['migration', 'energy & climate', 'East vs West', 'debt brake'],
|
||||
timeline: [
|
||||
{ year: '1923', label: 'hyperinflation' },
|
||||
{ year: '1933', label: 'Nazi dictatorship' },
|
||||
{ year: '1949', label: 'split + economic miracle' },
|
||||
{ year: '1990', label: 'reunification' },
|
||||
{ year: '2015', label: 'refugee crisis' }
|
||||
],
|
||||
blindSpots: ['debt as moral failing', 'Russian gas as "just trade"']
|
||||
},
|
||||
{
|
||||
id: 'fr',
|
||||
center: 'Strong central state, laïcité, protest as normal politics',
|
||||
fights: ['pensions', 'Paris vs periphery', 'Islam & laïcité', 'EU'],
|
||||
timeline: [
|
||||
{ year: '1789', label: 'Revolution invents left/right' },
|
||||
{ year: '1940', label: 'defeat and Vichy' },
|
||||
{ year: '1962', label: 'loses Algerian war' },
|
||||
{ year: '1968', label: "May '68" },
|
||||
{ year: '2018', label: 'Yellow Vests' }
|
||||
],
|
||||
blindSpots: ['colorblind universalism hides racism', 'influence in former African colonies']
|
||||
},
|
||||
{
|
||||
id: 'gb',
|
||||
center: 'Pragmatic and market-friendly, but the NHS is sacred',
|
||||
fights: ['Brexit', 'class', 'North vs South', 'Scotland'],
|
||||
timeline: [
|
||||
{ year: '1900', label: 'largest empire in history' },
|
||||
{ year: '1940', label: '"stood alone"' },
|
||||
{ year: '1948', label: 'NHS founded' },
|
||||
{ year: '1979', label: 'Thatcher' },
|
||||
{ year: '2016', label: 'Brexit vote' }
|
||||
],
|
||||
blindSpots: ['empire taught selectively', 'overrates the "special relationship"']
|
||||
},
|
||||
{
|
||||
id: 'se',
|
||||
center: 'High-trust, high-tax welfare state; very secular and individualist',
|
||||
fights: ['immigration', 'gang violence', 'private welfare', 'NATO'],
|
||||
timeline: [
|
||||
{ year: '1814', label: 'last war fought' },
|
||||
{ year: '1932', label: "Social Democrats' era" },
|
||||
{ year: '1939', label: 'neutral in WWII' },
|
||||
{ year: '2015', label: 'record asylum intake' },
|
||||
{ year: '2024', label: 'joins NATO' }
|
||||
],
|
||||
blindSpots: ['consensus makes disagreement taboo', 'neutrality myth (iron to Germany)']
|
||||
},
|
||||
{
|
||||
id: 'pl',
|
||||
center: 'Pro-NATO and wary of Russia across all parties',
|
||||
fights: ['Catholic vs liberal', 'abortion', 'rule of law', 'EU'],
|
||||
timeline: [
|
||||
{ year: '1795', label: 'erased by partition' },
|
||||
{ year: '1939', label: 'invaded by Germany and USSR' },
|
||||
{ year: '1980', label: 'Solidarity movement' },
|
||||
{ year: '1989', label: 'communism falls' },
|
||||
{ year: '2004', label: 'joins the EU' }
|
||||
],
|
||||
blindSpots: ['victimhood crowds out dark chapters', 'distrust of Germany']
|
||||
},
|
||||
{
|
||||
id: 'ru',
|
||||
center: 'Strong state and great-power status; opposition repressed',
|
||||
fights: ['largely suppressed in public'],
|
||||
timeline: [
|
||||
{ year: '1812', label: 'Napoleon invades' },
|
||||
{ year: '1941', label: 'Nazi invasion, ~27M dead' },
|
||||
{ year: '1991', label: 'USSR collapses' },
|
||||
{ year: '1990s', label: 'chaos and poverty' },
|
||||
{ year: '2022', label: 'full invasion of Ukraine' }
|
||||
],
|
||||
blindSpots: [
|
||||
'state-controlled media',
|
||||
'neighbors seen as sphere of influence',
|
||||
'Soviet crimes downplayed'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'cn',
|
||||
center: 'Order, growth and national rejuvenation, led by the Party',
|
||||
fights: ['youth jobs', 'property crisis', 'local corruption'],
|
||||
timeline: [
|
||||
{ year: '1839', label: 'Opium Wars, "humiliation"' },
|
||||
{ year: '1937', label: 'Japanese invasion' },
|
||||
{ year: '1966', label: 'Cultural Revolution' },
|
||||
{ year: '1978', label: 'reform and opening' },
|
||||
{ year: '1989', label: 'Tiananmen (unspoken)' }
|
||||
],
|
||||
blindSpots: ['censorship shapes history', 'criticism read as new humiliation']
|
||||
},
|
||||
{
|
||||
id: 'jp',
|
||||
center: 'Stability and harmony; one party governing since 1955',
|
||||
fights: ['pacifist constitution', 'aging & immigration', 'war memory'],
|
||||
timeline: [
|
||||
{ year: '1868', label: 'Meiji modernization' },
|
||||
{ year: '1937', label: 'war in Asia' },
|
||||
{ year: '1945', label: 'atomic bombs, defeat' },
|
||||
{ year: '1947', label: 'pacifist constitution' },
|
||||
{ year: '1990', label: 'bubble bursts' }
|
||||
],
|
||||
blindSpots: ['wartime atrocities taught thinly', 'sees itself as war victim']
|
||||
},
|
||||
{
|
||||
id: 'kr',
|
||||
center: 'Fierce democracy won by protest; hyper-competitive society',
|
||||
fights: ['North Korea', 'Japan', 'gender', 'chaebol power'],
|
||||
timeline: [
|
||||
{ year: '1910', label: 'Japanese colonization' },
|
||||
{ year: '1950', label: 'Korean War' },
|
||||
{ year: '1980', label: 'Gwangju massacre' },
|
||||
{ year: '1987', label: 'democracy' },
|
||||
{ year: '2024', label: 'martial law crisis' }
|
||||
],
|
||||
blindSpots: ['US and Japan seen through party lens']
|
||||
},
|
||||
{
|
||||
id: 'in',
|
||||
center: 'Largest democracy; religion, caste and language drive politics',
|
||||
fights: ['Hindu nationalism', 'caste quotas', 'language', 'center vs states'],
|
||||
timeline: [
|
||||
{ year: '1858', label: 'British Raj' },
|
||||
{ year: '1947', label: 'independence and Partition' },
|
||||
{ year: '1961', label: 'Non-Aligned Movement' },
|
||||
{ year: '1991', label: 'economic liberalization' },
|
||||
{ year: '2014', label: 'BJP majority' }
|
||||
],
|
||||
blindSpots: ['polarized media', 'reduced to poverty & religion by outsiders']
|
||||
},
|
||||
{
|
||||
id: 'br',
|
||||
center: 'Inequality makes redistribution central; religious society',
|
||||
fights: ['Lula vs Bolsonaro', 'crime', 'Amazon vs agribusiness'],
|
||||
timeline: [
|
||||
{ year: '1888', label: 'last to abolish slavery' },
|
||||
{ year: '1964', label: 'military coup' },
|
||||
{ year: '1985', label: 'democracy returns' },
|
||||
{ year: '1994', label: 'ends hyperinflation' },
|
||||
{ year: '2014', label: 'Car Wash scandal' }
|
||||
],
|
||||
blindSpots: ['"racial democracy" myth', 'WhatsApp information bubbles']
|
||||
},
|
||||
{
|
||||
id: 'tr',
|
||||
center: 'National pride, strong state, balancing West and region',
|
||||
fights: ['religious vs secular', 'Kurdish question', 'inflation'],
|
||||
timeline: [
|
||||
{ year: '1915', label: 'Armenian genocide (denied)' },
|
||||
{ year: '1920', label: 'Sèvres, fear of partition' },
|
||||
{ year: '1923', label: "Atatürk's secular republic" },
|
||||
{ year: '1980', label: 'military coup' },
|
||||
{ year: '2016', label: 'failed coup' }
|
||||
],
|
||||
blindSpots: ['genocide denial', 'restricted press', 'Western criticism read as hostility']
|
||||
}
|
||||
];
|
||||
|
||||
export const profileById = new Map(profiles.map((profile) => [profile.id, profile]));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One event, many stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Narrative = {
|
||||
event: string;
|
||||
years: string;
|
||||
views: { country: string; story: string }[];
|
||||
};
|
||||
|
||||
export const narratives: Narrative[] = [
|
||||
{
|
||||
event: 'World War II',
|
||||
years: '1939–45',
|
||||
views: [
|
||||
{ country: 'us', story: 'D-Day: democracy beats fascism' },
|
||||
{ country: 'ru', story: 'We broke Hitler, 27M dead' },
|
||||
{ country: 'cn', story: 'Our war began in 1937' },
|
||||
{ country: 'pl', story: 'Liberation became Soviet occupation' },
|
||||
{ country: 'jp', story: 'Hiroshima: we suffered' }
|
||||
]
|
||||
},
|
||||
{
|
||||
event: 'End of the Cold War',
|
||||
years: '1989–91',
|
||||
views: [
|
||||
{ country: 'us', story: 'We won: the end of history' },
|
||||
{ country: 'pl', story: 'We freed ourselves' },
|
||||
{ country: 'ru', story: 'Collapse and humiliation' },
|
||||
{ country: 'cn', story: 'A warning: never lose control' }
|
||||
]
|
||||
},
|
||||
{
|
||||
event: 'Colonialism',
|
||||
years: '1500s–1970s',
|
||||
views: [
|
||||
{ country: 'nl', story: 'Golden Age trade, now debated' },
|
||||
{ country: 'gb', story: 'Empire, remembered fondly by many' },
|
||||
{ country: 'in', story: 'Extraction and Partition' },
|
||||
{ country: 'cn', story: 'Century of humiliation' },
|
||||
{ country: 'br', story: 'Conquest, then US dominance' }
|
||||
]
|
||||
},
|
||||
{
|
||||
event: 'Iraq War',
|
||||
years: '2003',
|
||||
views: [
|
||||
{ country: 'us', story: 'Disarmament, later a mistake' },
|
||||
{ country: 'fr', story: 'We warned you' },
|
||||
{ country: 'iq', story: 'Invasion, then civil war' },
|
||||
{ country: 'ru', story: 'Proof the rules are selective' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const sources = [
|
||||
{
|
||||
name: 'World Values Survey / EVS cultural map',
|
||||
href: 'https://www.worldvaluessurvey.org/WVSContents.jsp?CMSID=Findings'
|
||||
},
|
||||
{ name: 'Chapel Hill Expert Survey', href: 'https://www.chesdata.eu/' },
|
||||
{ name: 'Manifesto Project', href: 'https://manifesto-project.wzb.eu/' },
|
||||
{ name: 'Pew Research Center', href: 'https://www.pewresearch.org/global/' },
|
||||
{ name: 'Natural Earth (map shapes)', href: 'https://www.naturalearthdata.com/' }
|
||||
];
|
||||
@@ -11,6 +11,7 @@
|
||||
{ name: 'osu! map finder', href: '/projects/spotify-beatmap-finder', external: false },
|
||||
{ name: 'game tracker', href: '/projects/game-tracker', external: false },
|
||||
{ name: 'money tracker', href: '/projects/money-tracker', external: false },
|
||||
{ name: 'world opinions', href: '/projects/world-opinions', external: false },
|
||||
{ name: 'vash esports', href: 'https://esports.vash.software', external: true },
|
||||
{ name: 'beerio kart', href: 'https://beeriokart.stanrunge.dev', external: true }
|
||||
] as const;
|
||||
|
||||
118
src/routes/projects/world-opinions/+page.svelte
Normal file
118
src/routes/projects/world-opinions/+page.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import './world-opinions.css';
|
||||
import CountryPanel from '$lib/world-opinions/CountryPanel.svelte';
|
||||
import CultureMap from '$lib/world-opinions/CultureMap.svelte';
|
||||
import PartyCompass from '$lib/world-opinions/PartyCompass.svelte';
|
||||
import WorldMap, { type MapMode } from '$lib/world-opinions/WorldMap.svelte';
|
||||
import { countries, countryById, narratives, sources } from '$lib/world-opinions/data';
|
||||
|
||||
const modes: { id: MapMode; label: string }[] = [
|
||||
{ id: 'liberal', label: 'what "liberal" means' },
|
||||
{ id: 'divide', label: 'what left vs right is about' },
|
||||
{ id: 'tradition', label: 'traditional ↔ secular' },
|
||||
{ id: 'expression', label: 'survival ↔ self-expression' }
|
||||
];
|
||||
const sorted = [...countries].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let mode = $state<MapMode>('liberal');
|
||||
let countryId = $state('us');
|
||||
let eventIndex = $state(0);
|
||||
let narrative = $derived(narratives[eventIndex]);
|
||||
|
||||
function select(id: string) {
|
||||
countryId = id;
|
||||
document.getElementById('country')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wo mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<header class="mb-8">
|
||||
<h1 class="mb-3 text-4xl font-semibold tracking-tight sm:text-5xl">world opinions</h1>
|
||||
<p class="text-lg text-[var(--site-muted)]">
|
||||
Same words, different politics. Hover the map, click a country.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section aria-label="World map">
|
||||
<div class="mb-4 flex flex-wrap gap-2" role="tablist" aria-label="Map layer">
|
||||
{#each modes as item (item.id)}
|
||||
<button
|
||||
class="wo-chip"
|
||||
role="tab"
|
||||
aria-selected={mode === item.id}
|
||||
onclick={() => (mode = item.id)}>{item.label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<WorldMap {mode} selected={countryId} onselect={select} />
|
||||
</section>
|
||||
|
||||
<section id="country" class="scroll-mt-20 pt-10" aria-label="Country">
|
||||
<label class="mb-3 block max-w-xs text-sm">
|
||||
<span class="sr-only">Country</span>
|
||||
<select
|
||||
bind:value={countryId}
|
||||
class="w-full rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-3 py-2"
|
||||
>
|
||||
{#each sorted as country (country.id)}
|
||||
<option value={country.id}>{country.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<CountryPanel id={countryId} />
|
||||
</section>
|
||||
|
||||
<section class="border-t border-[var(--site-border)] mt-12 py-10">
|
||||
<h2 class="mb-1 text-2xl font-semibold">party compass</h2>
|
||||
<p class="mb-5 text-[var(--site-muted)]">Every party on one shared scale.</p>
|
||||
<PartyCompass focus={countryId} />
|
||||
</section>
|
||||
|
||||
<section class="border-t border-[var(--site-border)] py-10">
|
||||
<h2 class="mb-1 text-2xl font-semibold">values map</h2>
|
||||
<p class="mb-5 text-[var(--site-muted)]">Both value axes at once.</p>
|
||||
<CultureMap selected={countryId} onselect={select} />
|
||||
</section>
|
||||
|
||||
<section class="border-t border-[var(--site-border)] py-10">
|
||||
<h2 class="mb-4 text-2xl font-semibold">one event, many stories</h2>
|
||||
<div class="mb-5 flex flex-wrap gap-2" role="tablist" aria-label="Event">
|
||||
{#each narratives as item, index (item.event)}
|
||||
<button
|
||||
class="wo-chip"
|
||||
role="tab"
|
||||
aria-selected={eventIndex === index}
|
||||
onclick={() => (eventIndex = index)}
|
||||
>{item.event.toLowerCase()} <span class="opacity-60">{item.years}</span></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div role="tabpanel">
|
||||
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each narrative.views as view (view.country)}
|
||||
<li>
|
||||
<button class="wo-card wo-quote w-full text-left" onclick={() => select(view.country)}>
|
||||
<span class="text-sm text-[var(--site-muted)]"
|
||||
>{countryById.get(view.country)?.name}</span
|
||||
>
|
||||
<span class="block text-lg">“{view.story}”</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="border-t border-[var(--site-border)] pt-6 text-sm text-[var(--site-muted)]">
|
||||
<p class="mb-2">Approximate, hand-placed positions for comparison. Based on:</p>
|
||||
<p class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{#each sources as source (source.href)}
|
||||
<a
|
||||
class="underline underline-offset-2 hover:text-[var(--site-text)]"
|
||||
href={source.href}
|
||||
rel="external">{source.name}</a
|
||||
>
|
||||
{/each}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
302
src/routes/projects/world-opinions/world-opinions.css
Normal file
302
src/routes/projects/world-opinions/world-opinions.css
Normal file
@@ -0,0 +1,302 @@
|
||||
.wo {
|
||||
--wo-s1: #3987e5;
|
||||
--wo-s2: #d95926;
|
||||
--wo-s3: #199e70;
|
||||
--wo-dot: #6b6b68;
|
||||
--wo-dot-dim: #3a3a38;
|
||||
--wo-grid: #1c1c1b;
|
||||
--wo-axis: #3a3a38;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .wo {
|
||||
--wo-s1: #2a78d6;
|
||||
--wo-s2: #eb6834;
|
||||
--wo-s3: #1baf7a;
|
||||
--wo-dot: #9a9994;
|
||||
--wo-dot-dim: #dcdbd6;
|
||||
--wo-grid: #f0efec;
|
||||
--wo-axis: #cfcec9;
|
||||
}
|
||||
|
||||
.wo-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.wo-chip:hover:not(:disabled) {
|
||||
background: var(--site-hover);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.wo-chip[aria-pressed='true'],
|
||||
.wo-chip[aria-selected='true'] {
|
||||
border-color: var(--site-text);
|
||||
color: var(--site-text);
|
||||
}
|
||||
.wo-chip:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.wo-swatch {
|
||||
display: inline-block;
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.wo-link {
|
||||
font-size: 0.875rem;
|
||||
color: var(--site-muted);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.wo-link:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
|
||||
.wo-chart svg text {
|
||||
font-family: inherit;
|
||||
}
|
||||
.wo-grid {
|
||||
stroke: var(--wo-grid);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.wo-axis {
|
||||
stroke: var(--wo-axis);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.wo-axis-label {
|
||||
fill: var(--site-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wo-label {
|
||||
fill: var(--site-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.wo-label-strong {
|
||||
fill: var(--site-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wo-dot {
|
||||
fill: var(--wo-dot);
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.wo-dot-dim {
|
||||
fill: var(--wo-dot-dim);
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.wo-dot-active {
|
||||
fill: var(--wo-s1);
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.wo-ring {
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.wo-chart g:focus-visible circle:last-of-type {
|
||||
stroke: var(--site-text);
|
||||
}
|
||||
|
||||
.wo-tooltip {
|
||||
position: absolute;
|
||||
transform: translate(-50%, calc(-100% - 14px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
pointer-events: none;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--site-surface);
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
}
|
||||
.wo-tooltip strong {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.wo-tooltip .max-w-64 {
|
||||
white-space: normal;
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
.wo-table {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.wo-table th,
|
||||
.wo-table td {
|
||||
border-top: 1px solid var(--site-border);
|
||||
padding: 0.4rem 0.5rem 0.4rem 0;
|
||||
text-align: left;
|
||||
}
|
||||
.wo-table th {
|
||||
color: var(--site-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.wo-card {
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--site-surface);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.wo {
|
||||
--wo-pos: #3987e5;
|
||||
--wo-neg: #d95926;
|
||||
--wo-mid: #4a4a47;
|
||||
--wo-none: #7a7a76;
|
||||
--wo-nodata: #161615;
|
||||
}
|
||||
:root[data-theme='light'] .wo {
|
||||
--wo-pos: #2a78d6;
|
||||
--wo-neg: #eb6834;
|
||||
--wo-mid: #d9d8d3;
|
||||
--wo-none: #a3a29c;
|
||||
--wo-nodata: #f1f1ef;
|
||||
}
|
||||
|
||||
.wo-country {
|
||||
stroke: var(--site-bg);
|
||||
stroke-width: 0.5;
|
||||
transition: opacity 120ms;
|
||||
}
|
||||
.wo-country-hover {
|
||||
stroke: var(--site-text);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.wo-country-selected {
|
||||
fill: none;
|
||||
stroke: var(--site-text);
|
||||
stroke-width: 1.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wo-tooltip-note {
|
||||
margin-top: 0.2rem;
|
||||
width: 15rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.wo-gradient {
|
||||
display: inline-block;
|
||||
width: 9rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(to right, var(--wo-neg), var(--wo-mid), var(--wo-pos));
|
||||
}
|
||||
.wo-swatch-nodata {
|
||||
background: var(--wo-nodata);
|
||||
outline: 1px solid var(--site-border);
|
||||
}
|
||||
|
||||
.wo-gauge {
|
||||
position: relative;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(to right, var(--wo-neg), var(--wo-mid), var(--wo-pos));
|
||||
}
|
||||
.wo-gauge-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 999px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--site-text);
|
||||
border: 3px solid var(--site-surface);
|
||||
transition: left 300ms ease;
|
||||
}
|
||||
|
||||
.wo-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-hover);
|
||||
padding: 0.2rem 0.7rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.wo-tag-dashed {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--site-border);
|
||||
color: var(--site-muted);
|
||||
}
|
||||
|
||||
.wo-timeline {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
position: relative;
|
||||
}
|
||||
.wo-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 1.85rem;
|
||||
border-top: 1px solid var(--site-border);
|
||||
}
|
||||
.wo-timeline li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.wo-timeline-year {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.wo-timeline-dot {
|
||||
position: relative;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: var(--site-text);
|
||||
}
|
||||
.wo-timeline-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.wo-timeline {
|
||||
grid-auto-flow: row;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.wo-timeline::before {
|
||||
display: none;
|
||||
}
|
||||
.wo-timeline li {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
}
|
||||
.wo-timeline-year {
|
||||
width: 3.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wo-quote:hover {
|
||||
background: var(--site-hover);
|
||||
}
|
||||
|
||||
.wo-tag-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-right: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
@@ -13,7 +13,8 @@ const staticPaths = [
|
||||
'/projects/catch-em-all',
|
||||
'/projects/animaldex',
|
||||
'/projects/derby',
|
||||
'/projects/spotify-beatmap-finder'
|
||||
'/projects/spotify-beatmap-finder',
|
||||
'/projects/world-opinions'
|
||||
];
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
|
||||
Reference in New Issue
Block a user