diff --git a/bun.lock b/bun.lock index bb6b277..c602a0b 100644 --- a/bun.lock +++ b/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=="], diff --git a/package-lock.json b/package-lock.json index bc0f8e1..460f1fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 915d992..d7325e6 100755 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 71e24a1..3ccc391 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -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', diff --git a/src/lib/seo.ts b/src/lib/seo.ts index fcb9ec5..93e0d92 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -48,6 +48,11 @@ const publicPages: Record = { '/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.' } }; diff --git a/src/lib/title.ts b/src/lib/title.ts index 48f4b39..ae5ae50 100644 --- a/src/lib/title.ts +++ b/src/lib/title.ts @@ -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 = diff --git a/src/lib/world-opinions/CountryPanel.svelte b/src/lib/world-opinions/CountryPanel.svelte new file mode 100644 index 0000000..1504159 --- /dev/null +++ b/src/lib/world-opinions/CountryPanel.svelte @@ -0,0 +1,95 @@ + + +{#if country} +
+
+

{country.name}

+ {regions[country.region]} +
+ + {#if profile}

{profile.center}

{/if} + +
+
+ {#if country.values} + {#each [{ label: ['traditional', 'secular'], value: country.values.y }, { label: ['survival', 'self-expression'], value: country.values.x }] as gauge (gauge.label[0])} +
+ +
+ {gauge.label[0]}{gauge.label[1]} +
+ {gauge.label[0]} to {gauge.label[1]}: {gauge.value} +
+ {/each} + {/if} +
+ {#if country.liberal} + "liberal" = {liberalMeanings[ + country.liberal + ]} + {/if} + {#if country.divide} + left vs + right: {divides[country.divide]} + {/if} +
+
+ + {#if profile} +
+
+ + {#each profile.fights as fight (fight)} + {fight} + {/each} +
+
+ + {#each profile.blindSpots as spot (spot)} + {spot} + {/each} +
+
+ {/if} +
+ + {#if profile} +
    + {#each profile.timeline as moment (moment.year)} +
  1. + {moment.year} + + {moment.label} +
  2. + {/each} +
+ {/if} +
+{/if} diff --git a/src/lib/world-opinions/CultureMap.svelte b/src/lib/world-opinions/CultureMap.svelte new file mode 100644 index 0000000..c6e2bf3 --- /dev/null +++ b/src/lib/world-opinions/CultureMap.svelte @@ -0,0 +1,224 @@ + + +
+
+ + {#each usedRegions as id (id)} + + {/each} +
+ +
+ + {#each ticks as tick (tick)} + + + {/each} + + + + ← survival + + self-expression → + + + ← traditional + + + secular-rational → + + + {#each ordered as point (point.id)} + {@const active = highlighted(point.id, point.region)} + {@const label = labels[point.id]} + (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); + } + }} + > + + + {#if label} + {point.name} + {/if} + + {/each} + + {#if hoveredPoint} +
+ {hoveredPoint.name} + {regions[hoveredPoint.region]} + {hoveredPoint.y >= 0 ? 'secular' : 'traditional'} + {Math.abs(hoveredPoint.y).toFixed(1)} · + {hoveredPoint.x >= 0 ? 'self-expression' : 'survival'} + {Math.abs(hoveredPoint.x).toFixed(1)} +
+ {/if} +
+ + + {#if showTable} + + + + + + {#each [...culturalMap].sort((a, b) => a.name.localeCompare(b.name)) as point (point.id)} + + + + {/each} + +
countryregiontraditional ↔ secularsurvival ↔ self-expression
{point.name}{regions[point.region]}{point.y.toFixed(1)}{point.x.toFixed(1)}
+ {/if} +
diff --git a/src/lib/world-opinions/PartyCompass.svelte b/src/lib/world-opinions/PartyCompass.svelte new file mode 100644 index 0000000..9e13763 --- /dev/null +++ b/src/lib/world-opinions/PartyCompass.svelte @@ -0,0 +1,200 @@ + + +
+
+ {#each partyCountries as id (id)} + {@const slot = slotOf(id)} + + {/each} +
+ +
+ {#each slots as id, index (index)} + {#if id} + + {countryName( + id + )} + + {/if} + {/each} +
+ +
+ + {#each ticks as tick (tick)} + + + {/each} + + + + ← economic left + + economic right → + + + traditional / authoritarian + + + progressive / libertarian + + + {#each visible as party (key(party))} + {@const color = `var(--wo-s${slotOf(party.country) + 1})`} + (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; + }} + > + + + {party.short} + + {/each} + + {#if hoveredParty} +
+ {hoveredParty.name} + {countryName(hoveredParty.country)} + economic {hoveredParty.econ.toFixed(1)} · social {hoveredParty.social.toFixed(1)} + {#if hoveredParty.note}{hoveredParty.note}{/if} +
+ {/if} +
+ + + {#if showTable} + + + + + + {#each visible as party (key(party))} + + + + {/each} + +
partycountryeconomic (0 left, 10 right)social (0 progressive, 10 traditional)
{party.name} ({party.short}){countryName(party.country)}{party.econ.toFixed(1)}{party.social.toFixed(1)}
+ {/if} +
diff --git a/src/lib/world-opinions/WorldMap.svelte b/src/lib/world-opinions/WorldMap.svelte new file mode 100644 index 0000000..6d219eb --- /dev/null +++ b/src/lib/world-opinions/WorldMap.svelte @@ -0,0 +1,174 @@ + + + + + + +
(hovered = null)} {onclick}> + + {#each shapes as shape (shape.iso + shape.name)} + {@const country = byIso.get(shape.iso)} + + {/each} + {#if selectedShape} + + {/if} + + + {#if hovered} +
+ {hovered.name} + {#if hoveredCountry} + {describe(hoveredCountry)} + {#if hoveredCountry.note && (mode === 'liberal' || mode === 'divide')} + {hoveredCountry.note} + {/if} + {:else} + no data + {/if} +
+ {/if} +
+ +
+ {#if mode === 'tradition' || mode === 'expression'} + + {poles[mode][0]} + + {poles[mode][1]} + + {:else} + {#each Object.entries(mode === 'liberal' ? liberalMeanings : divides) as [key, label] (key)} + + {label} + + {/each} + {/if} + + no data + +
diff --git a/src/lib/world-opinions/data.test.ts b/src/lib/world-opinions/data.test.ts new file mode 100644 index 0000000..21b33d1 --- /dev/null +++ b/src/lib/world-opinions/data.test.ts @@ -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); + } + }); +}); diff --git a/src/lib/world-opinions/data.ts b/src/lib/world-opinions/data.ts new file mode 100644 index 0000000..b510ccb --- /dev/null +++ b/src/lib/world-opinions/data.ts @@ -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 = { + '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 = { + 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 = { + 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/' } +]; diff --git a/src/routes/projects/+page.svelte b/src/routes/projects/+page.svelte index 878463a..b56d3b6 100644 --- a/src/routes/projects/+page.svelte +++ b/src/routes/projects/+page.svelte @@ -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; diff --git a/src/routes/projects/world-opinions/+page.svelte b/src/routes/projects/world-opinions/+page.svelte new file mode 100644 index 0000000..6b35201 --- /dev/null +++ b/src/routes/projects/world-opinions/+page.svelte @@ -0,0 +1,118 @@ + + +
+
+

world opinions

+

+ Same words, different politics. Hover the map, click a country. +

+
+ +
+
+ {#each modes as item (item.id)} + + {/each} +
+ +
+ +
+ + +
+ +
+

party compass

+

Every party on one shared scale.

+ +
+ +
+

values map

+

Both value axes at once.

+ +
+ +
+

one event, many stories

+
+ {#each narratives as item, index (item.event)} + + {/each} +
+
+
    + {#each narrative.views as view (view.country)} +
  • + +
  • + {/each} +
+
+
+ + +
diff --git a/src/routes/projects/world-opinions/world-opinions.css b/src/routes/projects/world-opinions/world-opinions.css new file mode 100644 index 0000000..78372e0 --- /dev/null +++ b/src/routes/projects/world-opinions/world-opinions.css @@ -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); +} diff --git a/src/routes/sitemap.xml/+server.ts b/src/routes/sitemap.xml/+server.ts index 5d228c0..412412a 100644 --- a/src/routes/sitemap.xml/+server.ts +++ b/src/routes/sitemap.xml/+server.ts @@ -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 {