diff --git a/package.json b/package.json index 469bd14..34c3140 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,13 @@ "@tailwindcss/vite": "^4.3.0", "astro": "6.4.2", "astro-icon": "^1.1.5", + "d3": "^7.9.0", "sharp": "^0.35.3", "tailwindcss": "^4.3.0" }, "devDependencies": { "@astrojs/check": "^0.9.9", + "@types/d3": "^7.4.3", "prettier": "^3.8.3", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ad4be5..6b4f5a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: astro-icon: specifier: ^1.1.5 version: 1.1.5 + d3: + specifier: ^7.9.0 + version: 7.9.0 sharp: specifier: ^0.35.3 version: 0.35.3(@types/node@25.9.1) @@ -29,6 +32,9 @@ importers: '@astrojs/check': specifier: ^0.9.9 version: 0.9.9(prettier-plugin-astro@0.14.1)(prettier@3.8.3)(typescript@6.0.3) + '@types/d3': + specifier: ^7.4.3 + version: 7.4.3 prettier: specifier: ^3.8.3 version: 3.8.3 @@ -1412,6 +1418,192 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/d3-array@3.2.2': + resolution: + { + integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==, + } + + '@types/d3-axis@3.0.6': + resolution: + { + integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==, + } + + '@types/d3-brush@3.0.6': + resolution: + { + integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==, + } + + '@types/d3-chord@3.0.6': + resolution: + { + integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==, + } + + '@types/d3-color@3.1.3': + resolution: + { + integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==, + } + + '@types/d3-contour@3.0.6': + resolution: + { + integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==, + } + + '@types/d3-delaunay@6.0.4': + resolution: + { + integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==, + } + + '@types/d3-dispatch@3.0.7': + resolution: + { + integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==, + } + + '@types/d3-drag@3.0.7': + resolution: + { + integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==, + } + + '@types/d3-dsv@3.0.7': + resolution: + { + integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==, + } + + '@types/d3-ease@3.0.2': + resolution: + { + integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==, + } + + '@types/d3-fetch@3.0.7': + resolution: + { + integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==, + } + + '@types/d3-force@3.0.10': + resolution: + { + integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==, + } + + '@types/d3-format@3.0.4': + resolution: + { + integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==, + } + + '@types/d3-geo@3.1.0': + resolution: + { + integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==, + } + + '@types/d3-hierarchy@3.1.7': + resolution: + { + integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==, + } + + '@types/d3-interpolate@3.0.4': + resolution: + { + integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, + } + + '@types/d3-path@3.1.1': + resolution: + { + integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==, + } + + '@types/d3-polygon@3.0.2': + resolution: + { + integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==, + } + + '@types/d3-quadtree@3.0.6': + resolution: + { + integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==, + } + + '@types/d3-random@3.0.4': + resolution: + { + integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==, + } + + '@types/d3-scale-chromatic@3.1.0': + resolution: + { + integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==, + } + + '@types/d3-scale@4.0.9': + resolution: + { + integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==, + } + + '@types/d3-selection@3.0.11': + resolution: + { + integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==, + } + + '@types/d3-shape@3.1.8': + resolution: + { + integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==, + } + + '@types/d3-time-format@4.0.3': + resolution: + { + integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==, + } + + '@types/d3-time@3.0.4': + resolution: + { + integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==, + } + + '@types/d3-timer@3.0.2': + resolution: + { + integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==, + } + + '@types/d3-transition@3.0.9': + resolution: + { + integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==, + } + + '@types/d3-zoom@3.0.8': + resolution: + { + integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==, + } + + '@types/d3@7.4.3': + resolution: + { + integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==, + } + '@types/debug@4.1.13': resolution: { @@ -1430,6 +1622,12 @@ packages: integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, } + '@types/geojson@7946.0.16': + resolution: + { + integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==, + } + '@types/hast@3.0.4': resolution: { @@ -1823,6 +2021,226 @@ packages: } engines: { node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0' } + d3-array@3.2.4: + resolution: + { + integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==, + } + engines: { node: '>=12' } + + d3-axis@3.0.0: + resolution: + { + integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==, + } + engines: { node: '>=12' } + + d3-brush@3.0.0: + resolution: + { + integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==, + } + engines: { node: '>=12' } + + d3-chord@3.0.1: + resolution: + { + integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==, + } + engines: { node: '>=12' } + + d3-color@3.1.0: + resolution: + { + integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==, + } + engines: { node: '>=12' } + + d3-contour@4.0.2: + resolution: + { + integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==, + } + engines: { node: '>=12' } + + d3-delaunay@6.0.4: + resolution: + { + integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==, + } + engines: { node: '>=12' } + + d3-dispatch@3.0.1: + resolution: + { + integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==, + } + engines: { node: '>=12' } + + d3-drag@3.0.0: + resolution: + { + integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==, + } + engines: { node: '>=12' } + + d3-dsv@3.0.1: + resolution: + { + integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==, + } + engines: { node: '>=12' } + hasBin: true + + d3-ease@3.0.1: + resolution: + { + integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==, + } + engines: { node: '>=12' } + + d3-fetch@3.0.1: + resolution: + { + integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==, + } + engines: { node: '>=12' } + + d3-force@3.0.0: + resolution: + { + integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==, + } + engines: { node: '>=12' } + + d3-format@3.1.2: + resolution: + { + integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==, + } + engines: { node: '>=12' } + + d3-geo@3.1.1: + resolution: + { + integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==, + } + engines: { node: '>=12' } + + d3-hierarchy@3.1.2: + resolution: + { + integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==, + } + engines: { node: '>=12' } + + d3-interpolate@3.0.1: + resolution: + { + integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==, + } + engines: { node: '>=12' } + + d3-path@3.1.0: + resolution: + { + integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==, + } + engines: { node: '>=12' } + + d3-polygon@3.0.1: + resolution: + { + integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==, + } + engines: { node: '>=12' } + + d3-quadtree@3.0.1: + resolution: + { + integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==, + } + engines: { node: '>=12' } + + d3-random@3.0.1: + resolution: + { + integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==, + } + engines: { node: '>=12' } + + d3-scale-chromatic@3.1.0: + resolution: + { + integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==, + } + engines: { node: '>=12' } + + d3-scale@4.0.2: + resolution: + { + integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==, + } + engines: { node: '>=12' } + + d3-selection@3.0.0: + resolution: + { + integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==, + } + engines: { node: '>=12' } + + d3-shape@3.2.0: + resolution: + { + integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==, + } + engines: { node: '>=12' } + + d3-time-format@4.1.0: + resolution: + { + integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==, + } + engines: { node: '>=12' } + + d3-time@3.1.0: + resolution: + { + integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==, + } + engines: { node: '>=12' } + + d3-timer@3.0.1: + resolution: + { + integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==, + } + engines: { node: '>=12' } + + d3-transition@3.0.1: + resolution: + { + integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==, + } + engines: { node: '>=12' } + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: + { + integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==, + } + engines: { node: '>=12' } + + d3@7.9.0: + resolution: + { + integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==, + } + engines: { node: '>=12' } + debug@4.4.3: resolution: { @@ -1847,6 +2265,12 @@ packages: integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==, } + delaunator@5.1.0: + resolution: + { + integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==, + } + dequal@2.0.3: resolution: { @@ -2243,6 +2667,13 @@ packages: } engines: { node: '>=0.10.0' } + internmap@2.0.3: + resolution: + { + integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==, + } + engines: { node: '>=12' } + iron-webcrypto@1.2.1: resolution: { @@ -3233,6 +3664,12 @@ packages: integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==, } + robust-predicates@3.0.3: + resolution: + { + integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==, + } + rollup@4.60.4: resolution: { @@ -3241,6 +3678,12 @@ packages: engines: { node: '>=18.0.0', npm: '>=8.0.0' } hasBin: true + rw@1.3.3: + resolution: + { + integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==, + } + s.color@0.0.15: resolution: { @@ -4655,6 +5098,123 @@ snapshots: tailwindcss: 4.3.0 vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0) + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -4663,6 +5223,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -4986,6 +5548,158 @@ snapshots: dependencies: css-tree: 2.2.1 + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -4996,6 +5710,10 @@ snapshots: defu@6.1.7: {} + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + dequal@2.0.3: {} destr@2.0.5: {} @@ -5277,6 +5995,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + internmap@2.0.3: {} + iron-webcrypto@1.2.1: {} is-docker@3.0.0: {} @@ -5967,6 +6687,8 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 + robust-predicates@3.0.3: {} + rollup@4.60.4: dependencies: '@types/estree': 1.0.8 @@ -5998,6 +6720,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 + rw@1.3.3: {} + s.color@0.0.15: {} safer-buffer@2.1.2: {} diff --git a/src/components/RingGraph.astro b/src/components/RingGraph.astro index d7f5df1..0e5b16b 100644 --- a/src/components/RingGraph.astro +++ b/src/components/RingGraph.astro @@ -1,21 +1,62 @@ --- -/* - * STUB — placeholder for the ring graph visualization. - * - * Two volunteer tickets build this out: - * 1. "Ring graph viz" — render members as nodes (force-directed / canvas / - * SVG) inside this panel. Use the webring collection or the generated - * ring data. Carleton-themed, reads design tokens via var(--color-*). - * 2. "Ring graph search" — search by name/year that pans/focuses a node and - * highlights matches (depends on the viz ticket). - * - * Kept as its own component so the page layout (webring.astro) is stable and - * the graph tickets only touch this file. - */ +import { getCollection } from 'astro:content'; + +const students = await getCollection('webring'); + +const nodes = students + .filter((student) => student.data.inRing) + .sort((studentA, studentB) => { + // Compare graduation year + if (studentA.data.year < studentB.data.year) return -1; + if (studentA.data.year > studentB.data.year) return 1; + // If graduation year is the same, compare student names + return studentA.data.name.localeCompare(studentB.data.name); + }) + .map((student) => ({ + id: student.id, + name: student.data.name, + url: student.data.url, + year: student.data.year, + inRing: student.data.inRing, + })); + +const links = []; + +for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + if (nodes[i].year === nodes[j].year) { + links.push({ + source: nodes[i].id, + target: nodes[j].id, + }); + } + } +} + +const graph = { nodes, links }; ---
- Ring graph — coming soon + + +
+
+
+
+
+
+ + diff --git a/src/scripts/webring-graph.ts b/src/scripts/webring-graph.ts new file mode 100644 index 0000000..f23a392 --- /dev/null +++ b/src/scripts/webring-graph.ts @@ -0,0 +1,374 @@ +import * as d3 from 'd3'; + +// #region Types + +interface StudentNode extends d3.SimulationNodeDatum { + id: string; + name: string; + url: string; + year: number; + inRing: boolean; +} + +interface GraphLinkData { + source: string; + target: string; +} + +interface GraphData { + nodes: StudentNode[]; + links: GraphLinkData[]; +} + +interface GraphLink { + source: StudentNode; + target: StudentNode; +} + +// #endregion + +// #region DOM elements + +const dataElement = document.querySelector('#webring-data'); + +const svgElement = document.querySelector('#webring-graph-svg'); + +const tooltipElement = + document.querySelector('#webring-tooltip'); + +if (!dataElement || !svgElement) { + throw new Error('Webring graph elements could not be found.'); +} + +// #endregion + +// #region Parse graph data + +const dataText = dataElement.textContent; + +if (!dataText) { + throw new Error('Webring graph data could not be found.'); +} + +let graphData: GraphData; + +try { + graphData = JSON.parse(dataText); +} catch { + throw new Error('Webring graph data contains invalid JSON.'); +} + +// #endregion + +// #region SVG setup + +const container = svgElement.parentElement; + +if (!container) { + throw new Error('Webring graph container could not be found.'); +} + +let width = container.clientWidth || 800; +let height = width; + +const svg = d3.select(svgElement); +svg + .attr('width', width) + .attr('height', height) + .attr('viewBox', `0 0 ${width} ${height}`) + .attr('style', 'max-width: 100%; height: auto;'); + +const graphGroup = svg + .append('g') + .attr('id', 'ring-graph') + .attr('class', 'ring-graph'); + +// #endregion + +// #region Zooming/Panning + +const zoom = d3 + .zoom() + .scaleExtent([0.75, 1.25]) + .extent([ + [0, 0], + [width, height], + ]) + .translateExtent([ + [-100, -100], + [width + 100, height + 100], + ]) + .on('zoom', (event) => { + graphGroup.attr('transform', event.transform); + }); + +svg.call(zoom); + +// #endregion + +// #region Links + +const visibleNodes = graphData.nodes; + +const nodeById = new Map(visibleNodes.map((node) => [node.id, node])); + +const visibleLinks: GraphLink[] = graphData.links.map((link) => { + const source = nodeById.get(link.source); + const target = nodeById.get(link.target); + + if (!source || !target) { + throw new Error(`Invalid graph link: ${link.source} -> ${link.target}`); + } + + return { source, target }; +}); + +const linkGroup = graphGroup.append('g').attr('class', 'ring-edge'); + +const links = linkGroup + .selectAll('line') + .data(visibleLinks) + .join('line') + .attr('class', 'ring-edge'); + +// #endregion + +// #region Nodes + +const nodeGroup = graphGroup.append('g').attr('class', 'ring-nodes'); + +const nodes = nodeGroup + .selectAll('g') + .data(visibleNodes) + .join('g') + .attr('class', 'ring-node is-match') + .attr('tabindex', 0) + .attr('role', 'link') + .attr('aria-label', (d) => `${d.name}, class of ${d.year}`) + .attr('data-name', (d) => d.name) + .attr('data-year', (d) => String(d.year)) + .attr('data-url', (d) => d.url); + +// Node circles +nodes.append('circle').attr('class', 'ring-node-circle').attr('r', 10); + +// Node labels +nodes + .append('text') + .attr('class', 'ring-label') + .attr('x', 14) + .attr('y', 4) + .text((d) => d.name); + +// Clickable nodes +nodes.on('click', (_event, d) => { + window.open(d.url, '_blank', 'noopener,noreferrer'); +}); + +// Keyboard accessibility +nodes.on('keydown', (event, d) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + + window.open(d.url, '_blank', 'noopener,noreferrer'); + } +}); + +// #endregion + +// #region Tooltip + +if (tooltipElement) { + const tooltip = d3.select(tooltipElement); + const tooltipName = tooltip.select('.tooltip-name'); + const tooltipYear = tooltip.select('.tooltip-year'); + const tooltipStatus = tooltip.select('.tooltip-status'); + const tooltipHostname = tooltip.select('.tooltip-hostname'); + + function showTooltip(event: MouseEvent, d: StudentNode) { + tooltip + .style('display', 'block') + .style('left', `${event.clientX + 12}px`) + .style('top', `${event.clientY + 12}px`); + + tooltipName.text(d.name); + tooltipYear.text(`Class of ${d.year}`); + tooltipStatus.text(`Currently in the webring`); + tooltipHostname.text(d.url); + } + + function hideTooltip() { + tooltip.style('display', 'none'); + } + + nodes + .select('.ring-node-circle') + .on('mouseenter', showTooltip) + .on('mousemove', showTooltip) + .on('mouseleave', hideTooltip); +} + +// #endregion + +// #region Boundary containment + +const nodeRadius = 10; +const boundaryPadding = nodeRadius + 4; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +// #endregion + +// #region Force simulation + +const simulation = d3 + .forceSimulation(visibleNodes) + .force( + 'link', + d3 + .forceLink(visibleLinks) + .id((d) => d.id) + .distance(100) + .strength(0.7), + ) + .force('charge', d3.forceManyBody().strength(-300)) + .force('collision', d3.forceCollide().radius(45)); + +function updateForces(): void { + simulation + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('x', d3.forceX(width / 2).strength(0.1)) + .force('y', d3.forceY(height / 2).strength(0.1)); +} + +updateForces(); + +const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)', +).matches; + +if (prefersReducedMotion) { + simulation.tick(300); + renderGraph(); + simulation.stop(); +} + +// #endregion + +// #region Dragging + +nodes.call( + d3 + .drag() + .on('start', (event, d) => { + if (!event.active) { + simulation.alphaTarget(0.2).restart(); + } + + d.fx = d.x; + d.fy = d.y; + }) + .on('drag', (event, d) => { + d.fx = clamp(event.x, boundaryPadding, width - boundaryPadding); + d.fy = clamp(event.y, boundaryPadding, height - boundaryPadding); + }) + .on('end', (event, d) => { + if (!event.active) { + simulation.alphaTarget(0); + } + + d.fx = null; + d.fy = null; + }), +); + +// #endregion + +// #region Simulation tick + +simulation.on('tick', renderGraph); + +function renderGraph(): void { + visibleNodes.forEach((d) => { + d.x = clamp(d.x ?? 0, boundaryPadding, width - boundaryPadding); + d.y = clamp(d.y ?? 0, boundaryPadding, height - boundaryPadding); + }); + + links + .attr('x1', (d) => d.source.x ?? 0) + .attr('y1', (d) => d.source.y ?? 0) + .attr('x2', (d) => d.target.x ?? 0) + .attr('y2', (d) => d.target.y ?? 0); + + nodes.attr('transform', (d) => `translate(${d.x ?? 0}, ${d.y ?? 0})`); +} + +// #endregion + +// #region Responsive resizing + +const resizeObserver = new ResizeObserver(() => { + width = container.clientWidth || 800; + height = width; + + svg + .attr('width', width) + .attr('height', height) + .attr('viewBox', `0 0 ${width} ${height}`); + + zoom + .extent([ + [0, 0], + [width, height], + ]) + .translateExtent([ + [-100, -100], + [width + 100, height + 100], + ]); + + updateForces(); + + if (prefersReducedMotion) { + simulation.tick(30); + renderGraph(); + simulation.stop(); + } else { + simulation.alpha(0.2).restart(); + } +}); + +resizeObserver.observe(container); + +// #endregion + +// #region Search hook (dormant) + +/* + * Exposed for the "Ring graph search" ticket. Not wired to any UI. + * Search is expected to call these once it can resolve a query to + * one or more student IDs. + * + * highlight(ids): + * Adds the "is-match" class to nodes whose id is in `ids`, and adds + * "is-dimmed" to all others, so search can visually emphasize matches + * without altering layout or removing any nodes/links. + * + * clear(): + * Removes "is-match"/"is-dimmed" from every node, restoring the default + * appearance. Should be called when the search query is cleared. + */ +export function highlight(ids: string[]): void { + const matchSet = new Set(ids); + + nodes + .classed('is-match', (d) => matchSet.has(d.id)) + .classed('is-dimmed', (d) => !matchSet.has(d.id)); +} + +export function clear(): void { + nodes.classed('is-match', false).classed('is-dimmed', false); +} + +// #endregion diff --git a/src/styles/global.css b/src/styles/global.css index 7e3e836..548bfbd 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -73,3 +73,66 @@ html.dark { padding-inline: 0; } } + +/* + * Webring Graph Styling + */ + +#webring-graph-svg { + display: block; + width: 100%; + height: 100%; +} + +/* + * Tooltip Styling + */ + +#webring-tooltip { + position: fixed; + display: none; + pointer-events: none; + z-index: 1000; + padding: 0.5rem 0.75rem; +} + +.tooltip-name { + font-weight: 600; +} + +.tooltip-year { + font-style: italic; +} + +.tooltip-hostname { + color: var(--color-accent); +} + +/* + * Ring Graph Styling + */ + +#ring-graph .ring-edge { + stroke: var(--color-line); +} + +#ring-graph .ring-node, +#ring-graph .ring-node-circle { + fill: var(--color-muted); + transition: fill 0.2s; + cursor: pointer; +} + +#ring-graph .ring-node-circle:hover, +#ring-graph .ring-node-circle.is-match { + fill: var(--color-accent); +} + +#ring-graph .ring-node-circle.is-dimmed { + opacity: 0.35; +} + +#ring-graph .ring-label { + fill: var(--color-muted); + font-size: 12px; +}