From 76e496c876a814eda54ba15484ef30835ec387e7 Mon Sep 17 00:00:00 2001 From: aurph Date: Mon, 3 Aug 2026 16:09:18 -0400 Subject: [PATCH] Social: rebuild the daily card with real type, real map, and provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card the daily poster attaches to every weekday tweet had four defects, three of them silent. satori was given one font file (Inter-Regular, weight 400) while the layout asked for fontWeight 700 and 800 on the wordmark, title and stat values, and fontFamily "monospace" on the numbers. satori cannot synthesize weights or substitute a family it was never given, so all of it rendered as Inter 400. Nothing errored; the card just looked flat, and had since it shipped. The frame also used justify-content: space-between with two children on a 630px canvas, leaving roughly 300px of dead space through the middle, and it showed no chart, no as-of date and no source. For a product whose claim is "tracked with sourced numbers", a card that shows three numbers with no date and no provenance argues against itself. Changes: - server/og-card.ts owns layout, fonts and the map. routes.ts only gathers data. asOf and source are required fields on OgCard, so a card physically cannot ship without saying when it was measured and where it came from. A missing date prints "AS OF —" rather than quietly substituting today. - Registers Inter-Bold and JetBrainsMono-Bold (both OFL, same licence as the Inter already bundled). Stat values now render in the brand mono. - Each weekday template gets a visual built from the same data as its tweet: Monday the 235-cluster US map, Tuesday the GPU price ladder, Wednesday the spotlight cluster located on the map, Thursday the queue breakdown, Friday the energy mix. - server/us-map.ts is generated by scripts/generate-map-path.mjs and carries the outline plus closed-form Albers constants, so the server projects with plain arithmetic. Importing d3-geo would have meant require()-ing an ESM-only module from the CJS bundle, which throws on older Node, inside a try/catch that would have degraded it to silently image-less tweets. d3-geo is now a devDependency used by the generator and a parity test that pins the arithmetic to d3's own output. - Fixes Virginia in client/src/data/us-states.geo.json, whose outer ring was wound backwards. d3 reads that as the whole sphere minus Virginia, which put the continental bounds at [[-180,-90],[180,90]]. geoAlbersUsa's clipping hid it, so the homepage map has been fitting to a global extent and rendering ~18% undersized and off-centre (scale 329.7 vs 402.0). - scripts/preview-cards.ts renders every template to .card-preview/ for eyeballing. It posts nothing. Tests cover the two silent failures directly: a missing font face now fails the suite, and a truncated outline path fails assertMapPathIntact. satori silently truncates large inline SVG, which is why the outline must stay a single concatenated path. 300 tests pass, tsc clean on server, server bundle verified to contain no d3-geo reference. --- .gitignore | 2 + CLAUDE.md | 36 ++- client/src/data/us-states.geo.json | 2 +- package-lock.json | 1 + package.json | 1 + scripts/generate-map-path.mjs | 146 +++++++++ scripts/preview-cards.ts | 56 ++++ server/__tests__/og-card.test.ts | 202 +++++++++++++ server/fonts/Inter-Bold.woff | Bin 0 -> 23264 bytes server/fonts/JetBrainsMono-Bold.woff | Bin 0 -> 27996 bytes server/og-card.ts | 425 +++++++++++++++++++++++++++ server/routes.ts | 309 +++++++++++-------- server/us-map.ts | 29 ++ 13 files changed, 1076 insertions(+), 133 deletions(-) create mode 100644 scripts/generate-map-path.mjs create mode 100644 scripts/preview-cards.ts create mode 100644 server/__tests__/og-card.test.ts create mode 100644 server/fonts/Inter-Bold.woff create mode 100644 server/fonts/JetBrainsMono-Bold.woff create mode 100644 server/og-card.ts create mode 100644 server/us-map.ts diff --git a/.gitignore b/.gitignore index 70e48a1..c3b779b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ attached_assets/Pasted-* /tmp/ coverage/ .nyc_output/ + +.card-preview/ diff --git a/CLAUDE.md b/CLAUDE.md index e183779..a5efd9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ The G-chord list is maintained twice in App.tsx (display ~67-83, handler ~250-25 d3 ^7.9.0 (Supply Chain force graph only). - Server libs: yahoo-finance2 ^3.13.1, rss-parser, satori + @resvg/resvg-js (OG cards), zod, helmet, express-rate-limit. -- Tests: Node's built-in runner. `npm test` = server/__tests__ + client/src/lib/__tests__ (217 tests). No component/DOM tests. +- Tests: Node's built-in runner. `npm test` = server/__tests__ + client/src/lib/__tests__ (300 tests). No component/DOM tests. - Scripts: `dev` (tsx), `build` (script/build.ts: vite client + esbuild server to dist/index.cjs), `start`, `check` (tsc), `test`, `backtest:indices`. - Persistence: JSON in server/data/ plus content/blog/articles.json. There is NO database, @@ -73,7 +73,7 @@ Derived from the code; starred rules confirmed by Jack 2026-07-02. marketing only. The landing has its own scoped tokens (.gt-marketing, client/src/styles/anchor.css). - Server layout: every route lives in server/routes.ts; math and fetching live in small pure modules routes call (indices.ts, clusters.ts, deals.ts, gpu-index.ts, gpu-economics.ts, - gpu-history.ts, brief.ts, physical.ts, social-format.ts). New module = pure module + thin + gpu-history.ts, brief.ts, physical.ts, social-format.ts, og-card.ts). New module = pure module + thin route + test. server/physical.ts is the house template: constant URL, in-memory TTL cache, typed honest degradation ({configured:false} when keyless, 502 on failure), never fabricate. - Frontier model data is public at `/api/frontier-models`. Benchmark records require a cited @@ -122,6 +122,38 @@ server/data custody (the fragility map): admin routes write into it). gpu-rental-prices.json is rewritten weekly by n8n; schema changes break the unattended refresh. +## 4b. Social / OG cards + +server/og-card.ts owns the layout, fonts and map for every card (/api/og and the +daily X post). routes.ts only gathers data; ogCardForTemplate returns an OgCard. + +- Every card MUST carry `asOf` and `source`. They are required fields, not + optional decoration: the product claim is "tracked with sourced numbers", so a + card showing a number without a date and a provenance line contradicts it. + Missing asOf prints "AS OF —"; never substitute today's date. +- Fonts: Inter-Regular.ttf (400), Inter-Bold.woff (700), JetBrainsMono-Bold.woff + (700), all in server/fonts/, all registered in og-card.ts. satori CANNOT + synthesize weights. Asking for a weight or family that is not registered + renders regular silently, which is how the pre-2026-08 card shipped flat for + months. server/__tests__/og-card.test.ts fails if a face goes missing. +- satori silently TRUNCATES large inline SVG markup. The US outline must stay a + single concatenated path; per-state elements exceeded the limit and + rendered a country missing most of its states with no error. Guarded by + assertMapPathIntact(). +- server/us-map.ts is GENERATED by scripts/generate-map-path.mjs. It carries the + outline path plus closed-form Albers constants, so the server projects + coordinates with plain arithmetic and never imports d3-geo, which is ESM-only + and would throw ERR_REQUIRE_ESM from the CJS bundle on older Node. d3-geo is a + devDependency used only by that script and the parity test. +- `npx tsx scripts/preview-cards.ts` renders every template to .card-preview/ + for eyeballing. It posts nothing. +- client/src/data/us-states.geo.json is the source geometry. The copy that + shipped on the elevate branch had Virginia's outer ring wound backwards, which + d3 reads as "the whole sphere minus Virginia". geoAlbersUsa's clipping hid it; + any non-composite projection does not. The committed copy is rewound, and the + generator rewinds + warns if a bad ring ever reappears. Client-side map work + should use this file, not the elevate copy. + ## 5. Do not touch - server/indices.ts constants (baselines, gains, clamps, NPI_BASE anchors, weights): published diff --git a/client/src/data/us-states.geo.json b/client/src/data/us-states.geo.json index 1c1ebe5..40f3325 100644 --- a/client/src/data/us-states.geo.json +++ b/client/src/data/us-states.geo.json @@ -45,7 +45,7 @@ {"type":"Feature","id":"48","properties":{"name":"Texas","density":98.07},"geometry":{"type":"Polygon","coordinates":[[[-101.812942,36.501861],[-100.000075,36.501861],[-100.000075,34.563024],[-99.923398,34.573978],[-99.698843,34.382285],[-99.57835,34.415147],[-99.260688,34.404193],[-99.189488,34.2125],[-98.986841,34.223454],[-98.767763,34.135823],[-98.570593,34.146777],[-98.488439,34.064623],[-98.36247,34.157731],[-98.170777,34.113915],[-98.088623,34.004376],[-97.946222,33.987946],[-97.869545,33.851022],[-97.694283,33.982469],[-97.458774,33.905791],[-97.371143,33.823637],[-97.256128,33.861976],[-97.173974,33.736006],[-96.922034,33.960561],[-96.850834,33.845545],[-96.631756,33.845545],[-96.423633,33.774345],[-96.346956,33.686714],[-96.149786,33.840068],[-95.936185,33.889361],[-95.8376,33.834591],[-95.602092,33.933176],[-95.547322,33.878407],[-95.289906,33.87293],[-95.224183,33.960561],[-94.966767,33.861976],[-94.868182,33.74696],[-94.484796,33.637421],[-94.380734,33.544313],[-94.183564,33.593606],[-94.041164,33.54979],[-94.041164,33.018527],[-94.041164,31.994339],[-93.822086,31.775262],[-93.816609,31.556184],[-93.542762,31.15089],[-93.526331,30.93729],[-93.630393,30.679874],[-93.728978,30.575812],[-93.696116,30.438888],[-93.767317,30.334826],[-93.690639,30.143133],[-93.926148,29.787132],[-93.838517,29.688547],[-94.002825,29.68307],[-94.523134,29.546147],[-94.70935,29.622824],[-94.742212,29.787132],[-94.873659,29.672117],[-94.966767,29.699501],[-95.016059,29.557101],[-94.911997,29.496854],[-94.895566,29.310638],[-95.081782,29.113469],[-95.383014,28.867006],[-95.985477,28.604113],[-96.045724,28.647929],[-96.226463,28.582205],[-96.23194,28.642452],[-96.478402,28.598636],[-96.593418,28.724606],[-96.664618,28.697221],[-96.401725,28.439805],[-96.593418,28.357651],[-96.774157,28.406943],[-96.801542,28.226204],[-97.026096,28.039988],[-97.256128,27.694941],[-97.404005,27.333463],[-97.513544,27.360848],[-97.540929,27.229401],[-97.425913,27.262263],[-97.480682,26.99937],[-97.557359,26.988416],[-97.562836,26.840538],[-97.469728,26.758384],[-97.442344,26.457153],[-97.332805,26.353091],[-97.30542,26.161398],[-97.217789,25.991613],[-97.524498,25.887551],[-97.650467,26.018997],[-97.885976,26.06829],[-98.198161,26.057336],[-98.466531,26.221644],[-98.669178,26.238075],[-98.822533,26.369522],[-99.030656,26.413337],[-99.173057,26.539307],[-99.266165,26.840538],[-99.446904,27.021277],[-99.424996,27.174632],[-99.50715,27.33894],[-99.479765,27.48134],[-99.605735,27.640172],[-99.709797,27.656603],[-99.879582,27.799003],[-99.934351,27.979742],[-100.082229,28.14405],[-100.29583,28.280974],[-100.399891,28.582205],[-100.498476,28.66436],[-100.629923,28.905345],[-100.673738,29.102515],[-100.799708,29.244915],[-101.013309,29.370885],[-101.062601,29.458516],[-101.259771,29.535193],[-101.413125,29.754271],[-101.851281,29.803563],[-102.114174,29.792609],[-102.338728,29.869286],[-102.388021,29.765225],[-102.629006,29.732363],[-102.809745,29.524239],[-102.919284,29.190146],[-102.97953,29.184669],[-103.116454,28.987499],[-103.280762,28.982022],[-103.527224,29.135376],[-104.146119,29.381839],[-104.266611,29.513285],[-104.507597,29.639255],[-104.677382,29.924056],[-104.688336,30.181472],[-104.858121,30.389596],[-104.896459,30.570335],[-105.005998,30.685351],[-105.394861,30.855136],[-105.602985,31.085167],[-105.77277,31.167321],[-105.953509,31.364491],[-106.205448,31.468553],[-106.38071,31.731446],[-106.528588,31.786216],[-106.643603,31.901231],[-106.616219,31.999816],[-103.067161,31.999816],[-103.067161,33.002096],[-103.045254,34.01533],[-103.039777,36.501861],[-103.001438,36.501861],[-101.812942,36.501861]]]}}, {"type":"Feature","id":"49","properties":{"name":"Utah","density":34.30},"geometry":{"type":"Polygon","coordinates":[[[-112.164359,41.995232],[-111.047063,42.000709],[-111.047063,40.998429],[-109.04798,40.998429],[-109.053457,39.125316],[-109.058934,38.27639],[-109.042503,38.166851],[-109.042503,37.000263],[-110.499369,37.00574],[-114.048427,37.000263],[-114.04295,41.995232],[-112.164359,41.995232]]]}}, {"type":"Feature","id":"50","properties":{"name":"Vermont","density":67.73},"geometry":{"type":"Polygon","coordinates":[[[-71.503554,45.013027],[-71.4926,44.914442],[-71.629524,44.750133],[-71.536416,44.585825],[-71.700724,44.41604],[-72.034817,44.322932],[-72.02934,44.07647],[-72.116971,43.994316],[-72.204602,43.769761],[-72.379864,43.572591],[-72.456542,43.150867],[-72.445588,43.008466],[-72.533219,42.953697],[-72.544173,42.80582],[-72.456542,42.729142],[-73.267129,42.745573],[-73.278083,42.833204],[-73.245221,43.523299],[-73.404052,43.687607],[-73.349283,43.769761],[-73.436914,44.043608],[-73.321898,44.246255],[-73.294514,44.437948],[-73.387622,44.618687],[-73.332852,44.804903],[-73.343806,45.013027],[-72.308664,45.002073],[-71.503554,45.013027]]]}}, -{"type":"Feature","id":"51","properties":{"name":"Virginia","density":204.5},"geometry":{"type":"MultiPolygon","coordinates":[[[[-75.397659,38.013497],[-75.244304,38.029928],[-75.375751,37.860142],[-75.512674,37.799896],[-75.594828,37.569865],[-75.802952,37.197433],[-75.972737,37.120755],[-76.027507,37.257679],[-75.939876,37.564388],[-75.671506,37.95325],[-75.397659,38.013497]]],[[[-76.016553,37.95325],[-75.994645,37.95325],[-76.043938,37.95325],[-76.016553,37.95325]]],[[[-78.349729,39.464886],[-77.82942,39.130793],[-77.719881,39.322485],[-77.566527,39.306055],[-77.456988,39.223901],[-77.456988,39.076023],[-77.248864,39.026731],[-77.117418,38.933623],[-77.040741,38.791222],[-77.128372,38.632391],[-77.248864,38.588575],[-77.325542,38.446175],[-77.281726,38.342113],[-77.013356,38.374975],[-76.964064,38.216144],[-76.613539,38.15042],[-76.514954,38.024451],[-76.235631,37.887527],[-76.3616,37.608203],[-76.246584,37.389126],[-76.383508,37.285064],[-76.399939,37.159094],[-76.273969,37.082417],[-76.410893,36.961924],[-76.619016,37.120755],[-76.668309,37.065986],[-76.48757,36.95097],[-75.994645,36.923586],[-75.868676,36.551154],[-79.510841,36.5402],[-80.294043,36.545677],[-80.978661,36.562108],[-81.679709,36.589492],[-83.673316,36.600446],[-83.136575,36.742847],[-83.070852,36.852385],[-82.879159,36.890724],[-82.868205,36.978355],[-82.720328,37.044078],[-82.720328,37.120755],[-82.353373,37.268633],[-81.969987,37.537003],[-81.986418,37.454849],[-81.849494,37.285064],[-81.679709,37.20291],[-81.55374,37.208387],[-81.362047,37.339833],[-81.225123,37.235771],[-80.967707,37.290541],[-80.513121,37.482234],[-80.474782,37.421987],[-80.29952,37.509618],[-80.294043,37.690357],[-80.184505,37.849189],[-79.998289,37.997066],[-79.921611,38.177805],[-79.724442,38.364021],[-79.647764,38.594052],[-79.477979,38.457129],[-79.313671,38.413313],[-79.209609,38.495467],[-78.996008,38.851469],[-78.870039,38.763838],[-78.404499,39.169131],[-78.349729,39.464886]]]]}}, +{"type":"Feature","id":"51","properties":{"name":"Virginia","density":204.5},"geometry":{"type":"MultiPolygon","coordinates":[[[[-75.397659,38.013497],[-75.244304,38.029928],[-75.375751,37.860142],[-75.512674,37.799896],[-75.594828,37.569865],[-75.802952,37.197433],[-75.972737,37.120755],[-76.027507,37.257679],[-75.939876,37.564388],[-75.671506,37.95325],[-75.397659,38.013497]]],[[[-76.016553,37.95325],[-76.043938,37.95325],[-75.994645,37.95325],[-76.016553,37.95325]]],[[[-78.349729,39.464886],[-77.82942,39.130793],[-77.719881,39.322485],[-77.566527,39.306055],[-77.456988,39.223901],[-77.456988,39.076023],[-77.248864,39.026731],[-77.117418,38.933623],[-77.040741,38.791222],[-77.128372,38.632391],[-77.248864,38.588575],[-77.325542,38.446175],[-77.281726,38.342113],[-77.013356,38.374975],[-76.964064,38.216144],[-76.613539,38.15042],[-76.514954,38.024451],[-76.235631,37.887527],[-76.3616,37.608203],[-76.246584,37.389126],[-76.383508,37.285064],[-76.399939,37.159094],[-76.273969,37.082417],[-76.410893,36.961924],[-76.619016,37.120755],[-76.668309,37.065986],[-76.48757,36.95097],[-75.994645,36.923586],[-75.868676,36.551154],[-79.510841,36.5402],[-80.294043,36.545677],[-80.978661,36.562108],[-81.679709,36.589492],[-83.673316,36.600446],[-83.136575,36.742847],[-83.070852,36.852385],[-82.879159,36.890724],[-82.868205,36.978355],[-82.720328,37.044078],[-82.720328,37.120755],[-82.353373,37.268633],[-81.969987,37.537003],[-81.986418,37.454849],[-81.849494,37.285064],[-81.679709,37.20291],[-81.55374,37.208387],[-81.362047,37.339833],[-81.225123,37.235771],[-80.967707,37.290541],[-80.513121,37.482234],[-80.474782,37.421987],[-80.29952,37.509618],[-80.294043,37.690357],[-80.184505,37.849189],[-79.998289,37.997066],[-79.921611,38.177805],[-79.724442,38.364021],[-79.647764,38.594052],[-79.477979,38.457129],[-79.313671,38.413313],[-79.209609,38.495467],[-78.996008,38.851469],[-78.870039,38.763838],[-78.404499,39.169131],[-78.349729,39.464886]]]]}}, {"type":"Feature","id":"53","properties":{"name":"Washington","density":102.6},"geometry":{"type":"MultiPolygon","coordinates":[[[[-117.033359,49.000239],[-117.044313,47.762451],[-117.038836,46.426077],[-117.055267,46.343923],[-116.92382,46.168661],[-116.918344,45.993399],[-118.988627,45.998876],[-119.125551,45.933153],[-119.525367,45.911245],[-119.963522,45.823614],[-120.209985,45.725029],[-120.505739,45.697644],[-120.637186,45.746937],[-121.18488,45.604536],[-121.217742,45.670259],[-121.535404,45.725029],[-121.809251,45.708598],[-122.247407,45.549767],[-122.762239,45.659305],[-122.811531,45.960537],[-122.904639,46.08103],[-123.11824,46.185092],[-123.211348,46.174138],[-123.370179,46.146753],[-123.545441,46.261769],[-123.72618,46.300108],[-123.874058,46.239861],[-124.065751,46.327492],[-124.027412,46.464416],[-123.895966,46.535616],[-124.098612,46.74374],[-124.235536,47.285957],[-124.31769,47.357157],[-124.427229,47.740543],[-124.624399,47.88842],[-124.706553,48.184175],[-124.597014,48.381345],[-124.394367,48.288237],[-123.983597,48.162267],[-123.704273,48.167744],[-123.424949,48.118452],[-123.162056,48.167744],[-123.036086,48.080113],[-122.800578,48.08559],[-122.636269,47.866512],[-122.515777,47.882943],[-122.493869,47.587189],[-122.422669,47.318818],[-122.324084,47.346203],[-122.422669,47.576235],[-122.395284,47.800789],[-122.230976,48.030821],[-122.362422,48.123929],[-122.373376,48.288237],[-122.471961,48.468976],[-122.422669,48.600422],[-122.488392,48.753777],[-122.647223,48.775685],[-122.795101,48.8907],[-122.756762,49.000239],[-117.033359,49.000239]]],[[[-122.718423,48.310145],[-122.586977,48.35396],[-122.608885,48.151313],[-122.767716,48.227991],[-122.718423,48.310145]]],[[[-123.025132,48.583992],[-122.915593,48.715438],[-122.767716,48.556607],[-122.811531,48.419683],[-123.041563,48.458022],[-123.025132,48.583992]]]]}}, {"type":"Feature","id":"54","properties":{"name":"West Virginia","density":77.06},"geometry":{"type":"Polygon","coordinates":[[[-80.518598,40.636951],[-80.518598,39.722302],[-79.477979,39.722302],[-79.488933,39.20747],[-79.291763,39.300578],[-79.094593,39.470363],[-78.963147,39.437501],[-78.765977,39.585379],[-78.470222,39.514178],[-78.431884,39.623717],[-78.267575,39.61824],[-78.174467,39.694917],[-78.004682,39.601809],[-77.834897,39.601809],[-77.719881,39.322485],[-77.82942,39.130793],[-78.349729,39.464886],[-78.404499,39.169131],[-78.870039,38.763838],[-78.996008,38.851469],[-79.209609,38.495467],[-79.313671,38.413313],[-79.477979,38.457129],[-79.647764,38.594052],[-79.724442,38.364021],[-79.921611,38.177805],[-79.998289,37.997066],[-80.184505,37.849189],[-80.294043,37.690357],[-80.29952,37.509618],[-80.474782,37.421987],[-80.513121,37.482234],[-80.967707,37.290541],[-81.225123,37.235771],[-81.362047,37.339833],[-81.55374,37.208387],[-81.679709,37.20291],[-81.849494,37.285064],[-81.986418,37.454849],[-81.969987,37.537003],[-82.101434,37.553434],[-82.293127,37.668449],[-82.342419,37.783465],[-82.50125,37.931343],[-82.621743,38.123036],[-82.594358,38.424267],[-82.331465,38.446175],[-82.293127,38.577622],[-82.172634,38.632391],[-82.221926,38.785745],[-82.03571,39.026731],[-81.887833,38.873376],[-81.783771,38.966484],[-81.811156,39.0815],[-81.685186,39.273193],[-81.57017,39.267716],[-81.455155,39.410117],[-81.345616,39.344393],[-81.219646,39.388209],[-80.830783,39.711348],[-80.737675,40.078303],[-80.600752,40.319289],[-80.595275,40.472643],[-80.666475,40.582182],[-80.518598,40.636951]]]}}, {"type":"Feature","id":"55","properties":{"name":"Wisconsin","density":105.2},"geometry":{"type":"Polygon","coordinates":[[[-90.415429,46.568478],[-90.229213,46.508231],[-90.119674,46.338446],[-89.09001,46.135799],[-88.662808,45.987922],[-88.531362,46.020784],[-88.10416,45.922199],[-87.989145,45.796229],[-87.781021,45.675736],[-87.791975,45.500474],[-87.885083,45.363551],[-87.649574,45.341643],[-87.742682,45.199243],[-87.589328,45.095181],[-87.627666,44.974688],[-87.819359,44.95278],[-87.983668,44.722749],[-88.043914,44.563917],[-87.928898,44.536533],[-87.775544,44.640595],[-87.611236,44.837764],[-87.403112,44.914442],[-87.238804,45.166381],[-87.03068,45.22115],[-87.047111,45.089704],[-87.189511,44.969211],[-87.468835,44.552964],[-87.545512,44.322932],[-87.540035,44.158624],[-87.644097,44.103854],[-87.737205,43.8793],[-87.704344,43.687607],[-87.791975,43.561637],[-87.912467,43.249452],[-87.885083,43.002989],[-87.76459,42.783912],[-87.802929,42.493634],[-88.788778,42.493634],[-90.639984,42.510065],[-90.711184,42.636034],[-91.067185,42.75105],[-91.143862,42.909881],[-91.176724,43.134436],[-91.056231,43.254929],[-91.204109,43.353514],[-91.215062,43.501391],[-91.269832,43.616407],[-91.242447,43.775238],[-91.43414,43.994316],[-91.592971,44.032654],[-91.877772,44.202439],[-91.927065,44.333886],[-92.233773,44.443425],[-92.337835,44.552964],[-92.545959,44.569394],[-92.808852,44.750133],[-92.737652,45.117088],[-92.75956,45.286874],[-92.644544,45.440228],[-92.770513,45.566198],[-92.885529,45.577151],[-92.869098,45.719552],[-92.639067,45.933153],[-92.354266,46.015307],[-92.29402,46.075553],[-92.29402,46.667063],[-92.091373,46.749217],[-92.014696,46.705401],[-91.790141,46.694447],[-91.09457,46.864232],[-90.837154,46.95734],[-90.749522,46.88614],[-90.886446,46.754694],[-90.55783,46.584908],[-90.415429,46.568478]]]}}, diff --git a/package-lock.json b/package-lock.json index c8491ae..bd0b1ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.20", + "d3-geo": "^3.1.0", "esbuild": "^0.25.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.17", diff --git a/package.json b/package.json index 2a07c1b..a701f92 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.7.0", "autoprefixer": "^10.4.20", + "d3-geo": "^3.1.0", "esbuild": "^0.25.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.17", diff --git a/scripts/generate-map-path.mjs b/scripts/generate-map-path.mjs new file mode 100644 index 0000000..d207b0c --- /dev/null +++ b/scripts/generate-map-path.mjs @@ -0,0 +1,146 @@ +// Bakes the continental-US outline used by the daily social card. +// +// Run: node scripts/generate-map-path.mjs +// Writes server/us-map.ts (committed). Re-run only if the card's map box +// changes size or client/src/data/us-states.geo.json is replaced. +// +// Why bake it: the server renders cards on demand and should not parse an +// 89 KB geojson or run geoPath per request. Baking the path plus the exact +// scale/translate lets the server rebuild the identical projection with +// geoAlbers().scale(S).translate(T), so cluster dots land on the outline. + +import { readFileSync, writeFileSync } from "fs"; +import { geoAlbers, geoPath, geoArea, geoBounds } from "d3-geo"; + +const MAP_W = 478; +const MAP_H = 290; +const SKIP = ["Alaska", "Hawaii", "Puerto Rico"]; + +const geo = JSON.parse(readFileSync("client/src/data/us-states.geo.json", "utf-8")); + +// Guard: a ring wound the wrong way reads as "the whole sphere minus this +// state" in d3's spherical math, which silently destroys the projection fit. +// Virginia shipped inverted; geoAlbersUsa's clipping hid it. Rewind and warn. +let rewound = 0; +for (const f of geo.features) { + const g = f.geometry; + if (!g) continue; + const polys = g.type === "Polygon" ? [g.coordinates] : g.type === "MultiPolygon" ? g.coordinates : []; + for (const poly of polys) { + if (poly[0] && geoArea({ type: "Polygon", coordinates: [poly[0]] }) > 2 * Math.PI) { + poly[0].reverse(); + rewound++; + console.warn(`rewound inverted outer ring: ${f.properties?.name}`); + } + } +} + +const continental = { + type: "FeatureCollection", + features: geo.features.filter((f) => !SKIP.includes(f.properties?.name)), +}; + +const [[w, s], [e, n]] = geoBounds(continental); +if (w < -126 || e > -66 || s < 23 || n > 50) { + throw new Error(`continental bounds look wrong: ${JSON.stringify(geoBounds(continental))}`); +} + +const projection = geoAlbers().fitSize([MAP_W, MAP_H], continental); + +// ── Bake the projection so the server needs no d3 at runtime ─────────────── +// +// The server bundle is CJS with every dependency marked external, and d3-geo +// is ESM-only. require()-ing it works on Node 22+ but throws ERR_REQUIRE_ESM +// on older runtimes, and the card render sits inside a try/catch, so a failure +// there would silently post image-less tweets forever. Instead we solve the +// closed-form Albers constants here and ship plain arithmetic. +// +// geoAlbers is conicEqualArea with parallels 29.5/45.5 and rotate([96, 0]): +// lambda' = lambda + 96deg +// rho = sqrt(c - 2n sin(phi)) / n +// theta = n * lambda' +// raw = [rho sin(theta), rho0 - rho cos(theta)] +// screen = [k * rawX + dx, dy - k * rawY] +// n, c and rho0 are analytic; k, dx and dy are solved against d3's own output +// so `center()`'s offset is captured exactly rather than re-derived. +const PARALLEL_0 = (29.5 * Math.PI) / 180; +const PARALLEL_1 = (45.5 * Math.PI) / 180; +const LAMBDA0_DEG = 96; + +const sy0 = Math.sin(PARALLEL_0); +const N = (sy0 + Math.sin(PARALLEL_1)) / 2; +const C = 1 + sy0 * (2 * N - sy0); +const RHO0 = Math.sqrt(C) / N; + +function rawAlbers(lat, lng) { + const lambda = ((lng + LAMBDA0_DEG) * Math.PI) / 180; + const phi = (lat * Math.PI) / 180; + const rho = Math.sqrt(C - 2 * N * Math.sin(phi)) / N; + const theta = lambda * N; + return [rho * Math.sin(theta), RHO0 - rho * Math.cos(theta)]; +} + +// Solve k, dx, dy from two reference points, then verify against many more. +const refA = [40.7128, -74.006]; +const refB = [37.7749, -122.4194]; +const [ax, ay] = rawAlbers(...refA); +const [bx, by] = rawAlbers(...refB); +const [AX, AY] = projection([refA[1], refA[0]]); +const [BX, BY] = projection([refB[1], refB[0]]); +const k = (AX - BX) / (ax - bx); +const dx = AX - k * ax; +const dy = AY + k * ay; + +let worst = 0; +for (let lat = 25; lat <= 49; lat += 1.5) { + for (let lng = -124; lng <= -67; lng += 2.5) { + const [rx, ry] = rawAlbers(lat, lng); + const mine = [k * rx + dx, dy - k * ry]; + const theirs = projection([lng, lat]); + worst = Math.max(worst, Math.hypot(mine[0] - theirs[0], mine[1] - theirs[1])); + } +} +if (worst > 1e-6) throw new Error(`baked projection drifts ${worst}px from d3`); +console.log(`baked projection verified: worst drift ${worst.toExponential(2)}px`); + +const scale = projection.scale(); +const [tx, ty] = projection.translate(); + +// One concatenated path, not one per state: satori truncates large inline SVG +// markup, and per-state elements blew that limit and rendered a +// half-drawn map. Keep this a single path. +const d = continental.features.map((f) => geoPath(projection)(f)).filter(Boolean).join(" "); + +const out = `// GENERATED by scripts/generate-map-path.mjs. Do not edit by hand. +// Continental US outline for the daily social card, projected with +// geoAlbers().fitSize([${MAP_W}, ${MAP_H}], continentalUS). + +export const MAP_W = ${MAP_W}; +export const MAP_H = ${MAP_H}; + +/** geoAlbers scale/translate, kept for reference and for the parity test. */ +export const MAP_SCALE = ${scale}; +export const MAP_TRANSLATE: [number, number] = [${tx}, ${ty}]; + +/** + * Closed-form Albers constants, solved against d3's own output and verified to + * better than 1e-6 px across the continental US. The server projects cluster + * coordinates with these instead of importing d3-geo, which is ESM-only and + * would throw ERR_REQUIRE_ESM from the CJS server bundle on older Node. + */ +export const ALBERS = { + n: ${N}, + c: ${C}, + rho0: ${RHO0}, + lambda0Deg: ${LAMBDA0_DEG}, + k: ${k}, + dx: ${dx}, + dy: ${dy}, +} as const; + +/** ${continental.features.length} continental states + DC, one concatenated path. */ +export const US_PATH = ${JSON.stringify(d)}; +`; + +writeFileSync("server/us-map.ts", out); +console.log(`wrote server/us-map.ts states=${continental.features.length} rewound=${rewound} pathChars=${d.length} scale=${scale.toFixed(2)}`); diff --git a/scripts/preview-cards.ts b/scripts/preview-cards.ts new file mode 100644 index 0000000..c319425 --- /dev/null +++ b/scripts/preview-cards.ts @@ -0,0 +1,56 @@ +// Renders every social card template to PNG so they can be eyeballed before +// they go out on X. Nothing here posts anything. +// +// Run: npx tsx scripts/preview-cards.ts [outDir] +// Default outDir: .card-preview/ (gitignored) + +import { mkdirSync, writeFileSync } from "fs"; +import { join } from "path"; +import { ogCardForTemplate } from "../server/routes.js"; +import { renderOgPng } from "../server/og-card.js"; + +const OUT = process.argv[2] ?? ".card-preview"; + +// The weekday rotation first, then the page/off-rotation templates. +const TEMPLATES = [ + "buildout", + "gpu_rental", + "cluster_spotlight", + "grid_backlog", + "power_mix", + "tilt_status", + "top_movers", + "npi_update", + "queue_update", + "catalyst_preview", +]; + +async function main() { + mkdirSync(OUT, { recursive: true }); + let failures = 0; + + for (const t of TEMPLATES) { + try { + const card = await ogCardForTemplate(t); + const png = await renderOgPng(card); + writeFileSync(join(OUT, `${t}.png`), png); + + // A card with no as-of or no source is a bug, not a style choice. + const warn: string[] = []; + if (!card.asOf) warn.push("NO as-of"); + if (!card.source) warn.push("NO source"); + console.log( + `${t.padEnd(20)} ${String(png.length).padStart(7)} B visual=${card.visual.kind.padEnd(8)}` + + `${warn.length ? " <-- " + warn.join(", ") : ""}`, + ); + } catch (err: any) { + failures++; + console.error(`${t.padEnd(20)} FAILED: ${err?.message}`); + } + } + + console.log(`\nwrote ${TEMPLATES.length - failures}/${TEMPLATES.length} cards to ${OUT}/`); + if (failures) process.exitCode = 1; +} + +main(); diff --git a/server/__tests__/og-card.test.ts b/server/__tests__/og-card.test.ts new file mode 100644 index 0000000..8ff139f --- /dev/null +++ b/server/__tests__/og-card.test.ts @@ -0,0 +1,202 @@ +// Guards the social card. Two of these lock in bugs that shipped silently for +// months, so read the comments before relaxing an assertion. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { buildCardTree, projectDot, assertMapPathIntact, formatAsOf, type OgCard } from "../og-card"; +import { MAP_W, MAP_H, MAP_SCALE, MAP_TRANSLATE, US_PATH } from "../us-map"; + +// ── Fonts ────────────────────────────────────────────────────────────────── +// +// The original card asked for fontWeight 700/800 and fontFamily "monospace" +// while registering only Inter-Regular. satori cannot synthesize weights, so +// every "bold" heading and every stat number silently rendered at weight 400 +// and in the wrong family. Nothing failed; the card just looked flat. If these +// files go missing the same silent regression returns. + +test("every font face the card asks for exists on disk", () => { + for (const f of ["Inter-Regular.ttf", "Inter-Bold.woff", "JetBrainsMono-Bold.woff"]) { + const p = join(process.cwd(), "server", "fonts", f); + assert.ok(existsSync(p), `missing font ${f}; card will silently render the wrong weight`); + assert.ok(readFileSync(p).length > 5_000, `${f} is suspiciously small`); + } +}); + +// ── Map geometry ─────────────────────────────────────────────────────────── +// +// satori truncates large inline SVG markup with no error. Splitting the US +// outline into per-state elements blew that limit and rendered a +// country missing 44 of its 49 states. Keep it one path, and keep it long. + +test("US outline is a single intact path", () => { + assertMapPathIntact(); + assert.ok(US_PATH.startsWith("M"), "path should start with a moveto"); + assert.ok(US_PATH.length > 30_000, `path is only ${US_PATH.length} chars; likely truncated`); +}); + +test("known cities project inside the map box, in the right places", () => { + const abilene = projectDot(32.4487, -99.7331); + const sanFrancisco = projectDot(37.7749, -122.4194); + const newYork = projectDot(40.7128, -74.006); + + for (const [name, p] of [["Abilene", abilene], ["SF", sanFrancisco], ["NYC", newYork]] as const) { + assert.ok(p, `${name} should project`); + assert.ok(p![0] >= 0 && p![0] <= MAP_W, `${name} x out of box`); + assert.ok(p![1] >= 0 && p![1] <= MAP_H, `${name} y out of box`); + } + // West coast must land left of the east coast, and Texas below New York. + assert.ok(sanFrancisco![0] < newYork![0], "SF should be west of NYC"); + assert.ok(abilene![1] > newYork![1], "Abilene should be south of NYC"); +}); + +test("coordinates outside the continental box are rejected, not clamped", () => { + assert.equal(projectDot(64.2008, -149.4937), null, "Alaska should not be plotted"); + assert.equal(projectDot(21.3099, -157.8581), null, "Hawaii should not be plotted"); +}); + +test("garbage coordinates return null instead of NaN pixels", () => { + assert.equal(projectDot(NaN, -99), null); + assert.equal(projectDot(40, Infinity), null); +}); + +// The runtime projection is d3's geoAlbers written out longhand so the CJS +// server bundle never has to require() the ESM-only d3-geo. That trade is only +// safe while the arithmetic matches d3 exactly, so pin it here. d3-geo is a +// devDependency and is used by this test and the generator script only. +test("baked projection matches d3-geo across the continental US", async () => { + const { geoAlbers } = await import("d3-geo"); + const reference = geoAlbers().scale(MAP_SCALE).translate(MAP_TRANSLATE); + + let worst = 0; + let compared = 0; + for (let lat = 25; lat <= 49; lat += 1) { + for (let lng = -124; lng <= -67; lng += 1) { + const mine = projectDot(lat, lng); + const theirs = reference([lng, lat]); + if (!mine || !theirs) continue; // box-clipped by us, not by d3 + worst = Math.max(worst, Math.hypot(mine[0] - theirs[0], mine[1] - theirs[1])); + compared++; + } + } + + assert.ok(compared > 500, `expected a dense sample, compared only ${compared} points`); + assert.ok(worst < 1e-6, `baked projection drifts ${worst}px from d3-geo`); +}); + +// ── as-of formatting ─────────────────────────────────────────────────────── + +test("formatAsOf renders a card-ready date or null", () => { + assert.equal(formatAsOf("2026-06-26"), "26 JUN 2026"); + assert.equal(formatAsOf("2026-01-05T12:00:00Z"), "5 JAN 2026"); + assert.equal(formatAsOf(null), null); + assert.equal(formatAsOf(""), null); + assert.equal(formatAsOf("not a date"), null); + assert.equal(formatAsOf("2026-13-01"), null, "month 13 has no name"); +}); + +// ── Frame ────────────────────────────────────────────────────────────────── + +/** Flatten the satori tree into the strings it will render. */ +function textsOf(node: unknown, out: string[] = []): string[] { + if (typeof node === "string") { + out.push(node); + return out; + } + if (Array.isArray(node)) { + node.forEach((n) => textsOf(n, out)); + return out; + } + if (node && typeof node === "object") { + const props = (node as { props?: { children?: unknown } }).props; + if (props && "children" in props) textsOf(props.children, out); + } + return out; +} + +const sample: OgCard = { + title: "the AI buildout, tracked", + subtitle: "every US compute cluster we count carries a public source", + stats: [{ label: "Clusters", value: "235" }], + asOf: "26 JUN 2026", + source: "235/235 entries sourced · company filings", + visual: { kind: "none" }, +}; + +test("the card always shows when it was measured and where it came from", () => { + const texts = textsOf(buildCardTree(sample)); + assert.ok(texts.includes("AS OF 26 JUN 2026"), "as-of missing from card"); + assert.ok(texts.includes(sample.source), "source line missing from card"); + assert.ok(texts.includes("GRIDTILT")); + assert.ok(texts.includes("gridtilt.com")); +}); + +test("a card with no as-of degrades honestly instead of inventing a date", () => { + const texts = textsOf(buildCardTree({ ...sample, asOf: null })); + assert.ok(texts.includes("AS OF —"), "missing as-of should print an em dash, not today"); + assert.ok( + !texts.some((t) => /\d{4}/.test(t) && t.startsWith("AS OF")), + "must not fabricate a year", + ); +}); + +test("long stat values step down in size so they cannot collide with the visual", () => { + const sizeOf = (value: string): number => { + const find = (n: any): number | null => { + if (n && typeof n === "object") { + if (n.props?.children === value && n.props?.style?.fontSize) { + return parseFloat(n.props.style.fontSize); + } + const kids = n.props?.children; + for (const k of Array.isArray(kids) ? kids : [kids]) { + const hit = find(k); + if (hit !== null) return hit; + } + } + return null; + }; + return find(buildCardTree({ ...sample, stats: [{ label: "L", value }] }))!; + }; + + assert.equal(sizeOf("235"), 44); + assert.ok(sizeOf("OpenAI / Oracle") < sizeOf("235"), "long values must shrink"); + assert.ok(sizeOf("A very long operator name") < sizeOf("OpenAI / Oracle"), "longer still must shrink further"); +}); + +test("all four visual kinds build without throwing", () => { + const kinds: OgCard["visual"][] = [ + { kind: "none" }, + { kind: "map", dots: [{ lat: 32.4487, lng: -99.7331, mw: 1200, status: "construction" }] }, + { kind: "bars", bars: [{ label: "ERCOT", value: 230, display: "230 GW", hot: true }] }, + { kind: "columns", columns: [{ label: "H100", value: 2.69, display: "$2.69" }] }, + ]; + for (const visual of kinds) { + assert.doesNotThrow(() => buildCardTree({ ...sample, visual }), `visual ${visual.kind} threw`); + } +}); + +test("bars scale against the largest value without dividing by zero", () => { + assert.doesNotThrow(() => + buildCardTree({ + ...sample, + visual: { kind: "bars", bars: [{ label: "empty", value: 0, display: "0 GW" }] }, + }), + ); +}); + +test("a highlighted map dot suppresses the status legend", () => { + const withHighlight = textsOf( + buildCardTree({ + ...sample, + visual: { + kind: "map", + dots: [ + { lat: 32.4487, lng: -99.7331, highlight: true }, + { lat: 40.7128, lng: -74.006 }, + ], + legend: true, + }, + }), + ); + assert.ok(!withHighlight.includes("operational"), "legend should be hidden when one dot is spotlit"); +}); diff --git a/server/fonts/Inter-Bold.woff b/server/fonts/Inter-Bold.woff new file mode 100644 index 0000000000000000000000000000000000000000..c2cd54d86af98bf61430bd53198855c7e404bf7f GIT binary patch literal 23264 zcmYg%19Tw!mYG-L7Api*AtEg=Q5dQHk24C^NnSY}HTZ9D!g#Z8$m9Lz_mpQ=>z?KWkNXvia zDgXdj0sw$EpsSA?T3B9@4*-Ch1_0nW002VpNE-EXY56~N001uR*KvwprsJ*n$|tYD z`^AH6@D+dI00NgAE5iDgx;9_AoG;D}0N`5yA93V~v9A4B4qxXB1NlEgG`4Ux`pTIA z0F1$3lqLynWZWi(x?f!KS6>*We~hYMSJ31u`6ZY1EB^Uqzwz&ph)paVT)uK5U;MQI z0GI)JOCN`Ywf+}Q>Fq0*@n!XYKn0pDbzQ#dRZaOi4)>qqLF_=lt#mC7zjF0o`Y^t5 zm^3sBTQ=7A4qxpz{)IvL2lqh_=yq9!>jwc3kO2tJxI^%8)%gHGgV5=VoML|a$ovFU zGC?BV0a`6-PY)Y^qLE8OrI{+RN6vL3sIW@LwG>jJ-;99DD9P-+fIsUpN+jpBWsv> zMgPc2%NGCXg;X2vf-9lAC{bprzlO`7;SAioM`{{=ve(K5gGxZv0n?E*+6`OiY>1qi zz79u;qSNrPNRpi6lZYJauQl>&zGfI}V8+XQE>ki0;p`-d+YakA<_?g2eIJj=$I7TF zM(-mTi{0n%UdSHHhdWvzAEZD7hZx%5dX{c}G*X-rXcd_pN-}5_KGG;6L9mDz`LuH3 z8OY{YEvXh4w~ofD0bARshcqH6Y_=_>v#7_-v(1bBjz=vnm%-+a)Xev(ac?2tWuv~t z%huV$&-))^eWetfdTWVIsE28-IAj1bJz+bVI0N}a3-?cy%uq+hpXDEZ<%VQKCH+*L zJ4I$Pq#4WCW^(Ryix|oTiNPAX0(d&aV`pN>U1K|O?~950E$AXxW#GYD6CjVP`Ag+3 zQp5<#&AFzREv?||u{6!G3BNlee$)O99HiMLB7hC*G_Y6iggXlP9ZIN32h|6<_@r-! z3!zpJYt-!}sc}BcFIJ}Ja(6gf$ z+r5&fH~$)m9Lu8usnZ5Nvrcw

@#uz(Vi=CXQ*+r8x}a-_*3<7o|KLvjleyKQXD0 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 literal 0 HcmV?d00001 diff --git a/server/og-card.ts b/server/og-card.ts new file mode 100644 index 0000000..f98073d --- /dev/null +++ b/server/og-card.ts @@ -0,0 +1,425 @@ +// ─── Social / OG card rendering ──────────────────────────────────────────── +// +// One shared frame for every card the daily poster and /api/og produce: +// +// ┌──────────────────────────────────────────────────────────┐ +// │ ▌GRIDTILT AS OF 26 JUN 2026│ header rule +// │ title ┌──────────────┐│ +// │ subtitle │ ││ +// │ │ visual ││ +// │ STAT STAT STAT └──────────────┘│ +// │ source line gridtilt.com│ footer rule +// └──────────────────────────────────────────────────────────┘ +// +// The as-of date and the source line are permanent furniture, not decoration. +// GridTilt's claim is "tracked with sourced numbers"; a card that shows a +// number without saying when it was measured or where it came from does not +// support that claim. Callers must supply both. +// +// Layout notes learned the hard way (see server/__tests__/og-card.test.ts): +// - satori cannot synthesize weights. Every face used here is registered +// below as a real file. Asking for fontWeight 700 without registering a +// bold face silently renders regular, which is what the old card did. +// - satori truncates large inline SVG markup. The US outline must stay ONE +// concatenated path (see scripts/generate-map-path.mjs); splitting it into +// per-state elements exceeded the limit and rendered a partial map +// with no error. assertMapPathIntact() guards against a regression. + +import { readFileSync } from "fs"; +import { join } from "path"; +import { MAP_W, MAP_H, ALBERS, US_PATH } from "./us-map.js"; + +// ─── Tokens (mirror client/src/lib/tokens.ts) ────────────────────────────── + +const BRAND = "#F07800"; +const AMBER = "#F0A500"; +const BG = "#121110"; +const PANEL = "#1b1a18"; +const HAIRLINE = "#2b2926"; +const STATE_STROKE = "#45423c"; +const MUTED = "#6b7280"; +const SUBTLE = "#9ca3af"; + +const CARD_W = 1200; +const CARD_H = 630; + +// ─── Public types ────────────────────────────────────────────────────────── + +export interface OgStat { + label: string; + value: string; +} + +/** A cluster plotted on the US map. Projection happens here, not in callers. */ +export interface MapDot { + lat: number; + lng: number; + /** Planned MW; drives dot radius. Null/0 renders at the smallest size. */ + mw?: number | null; + /** operational | construction | announced; drives opacity. */ + status?: string; + /** Draws this dot larger with a ring and dims the rest. Used by the spotlight card. */ + highlight?: boolean; +} + +export interface Bar { + label: string; + /** Raw magnitude; bar widths are normalised against the largest value. */ + value: number; + /** What to print at the end of the bar, e.g. "42 GW". */ + display: string; + /** Draws the bar in brand orange instead of grey. Use for the bottleneck. */ + hot?: boolean; +} + +export interface Column { + label: string; + value: number; + display?: string; +} + +export type OgVisual = + | { kind: "map"; dots: MapDot[]; legend?: boolean } + | { kind: "bars"; bars: Bar[] } + | { kind: "columns"; columns: Column[]; caption?: string } + | { kind: "none" }; + +export interface OgCard { + title: string; + subtitle: string; + stats: OgStat[]; + /** Pre-formatted, e.g. "26 JUN 2026". Null prints "AS OF —". */ + asOf: string | null; + /** Footer-left provenance line. Required; keep it specific and true. */ + source: string; + visual: OgVisual; +} + +// ─── Map projection ──────────────────────────────────────────────────────── + +const STATUS_OPACITY: Record = { + operational: 1, + construction: 0.72, + announced: 0.38, +}; + +/** + * Project a coordinate into the card's map box, or null when it falls outside. + * + * This is d3's geoAlbers written out longhand using constants baked by + * scripts/generate-map-path.mjs. It is deliberately dependency-free: d3-geo is + * ESM-only, the server ships as a CJS bundle with deps external, and the card + * render is wrapped in a try/catch that would swallow ERR_REQUIRE_ESM into + * silently image-less tweets. The parity test in server/__tests__ pins this to + * d3's own output. + */ +export function projectDot(lat: number, lng: number): [number, number] | null { + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + + const lambda = ((lng + ALBERS.lambda0Deg) * Math.PI) / 180; + const phi = (lat * Math.PI) / 180; + + const under = ALBERS.c - 2 * ALBERS.n * Math.sin(phi); + if (under < 0) return null; // beyond the projection's valid latitude range + + const rho = Math.sqrt(under) / ALBERS.n; + const theta = lambda * ALBERS.n; + const x = ALBERS.k * (rho * Math.sin(theta)) + ALBERS.dx; + const y = ALBERS.dy - ALBERS.k * (ALBERS.rho0 - rho * Math.cos(theta)); + + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + // The conic projection happily returns coordinates for the whole hemisphere, + // so anything off the card's map box (Alaska, Hawaii) is dropped, not clamped. + if (x < 0 || x > MAP_W || y < 0 || y > MAP_H) return null; + return [x, y]; +} + +function dotRadius(mw?: number | null): number { + const v = mw ?? 0; + if (v >= 1500) return 5.6; + if (v >= 600) return 4.2; + if (v >= 200) return 3.2; + return 2.5; +} + +/** + * Guard against the satori SVG truncation failure. The generated outline is + * ~36k characters; anything far below that means the file was regenerated + * wrongly or hand-edited, and the card would ship a half-drawn country. + */ +export function assertMapPathIntact(): void { + if (!US_PATH.startsWith("M") || US_PATH.length < 20_000) { + throw new Error(`US_PATH looks truncated (${US_PATH.length} chars); re-run scripts/generate-map-path.mjs`); + } +} + +function mapDataUri(): string { + assertMapPathIntact(); + const svg = + `` + + `` + + ``; + return "data:image/svg+xml;base64," + Buffer.from(svg).toString("base64"); +} + +// ─── Tiny satori node helpers ────────────────────────────────────────────── + +type Node = Record; + +const box = (style: Record, children: unknown[] = []): Node => ({ + type: "div", + props: { style: { display: "flex", ...style }, children }, +}); + +const text = (value: string, style: Record): Node => ({ + type: "div", + props: { style: { display: "flex", ...style }, children: value }, +}); + +// ─── Visual builders ─────────────────────────────────────────────────────── + +function buildMap(dots: MapDot[], legend: boolean): Node { + // When any dot is highlighted the rest recede, so the eye lands on one place. + const hasHighlight = dots.some((d) => d.highlight); + const placed = dots + .map((d) => { + const p = projectDot(d.lat, d.lng); + if (!p) return null; + const r = d.highlight ? 7 : dotRadius(d.mw); + const base = STATUS_OPACITY[d.status ?? ""] ?? 0.6; + const op = d.highlight ? 1 : hasHighlight ? 0.22 : base; + return { x: p[0], y: p[1], r, op, ring: !!d.highlight }; + }) + .filter((d): d is { x: number; y: number; r: number; op: number; ring: boolean } => d !== null) + // Draw the highlighted dot last so it sits on top of the crowd. + .sort((a, b) => Number(a.ring) - Number(b.ring)); + + const key = (opacity: number, label: string) => + box({ alignItems: "center", gap: "7px" }, [ + box({ width: "8px", height: "8px", borderRadius: "4px", backgroundColor: BRAND, opacity }), + text(label, { fontSize: "12px", color: MUTED }), + ]); + + return box({ flexDirection: "column", alignItems: "flex-end", gap: "10px" }, [ + box({ position: "relative", width: `${MAP_W}px`, height: `${MAP_H}px` }, [ + { type: "img", props: { src: mapDataUri(), width: MAP_W, height: MAP_H } }, + ...placed.flatMap((d) => [ + ...(d.ring + ? [ + box({ + position: "absolute", + left: `${d.x - d.r - 6}px`, + top: `${d.y - d.r - 6}px`, + width: `${(d.r + 6) * 2}px`, + height: `${(d.r + 6) * 2}px`, + borderRadius: `${d.r + 6}px`, + border: `2px solid ${AMBER}`, + }), + ] + : []), + box({ + position: "absolute", + left: `${d.x - d.r}px`, + top: `${d.y - d.r}px`, + width: `${d.r * 2}px`, + height: `${d.r * 2}px`, + borderRadius: `${d.r}px`, + backgroundColor: BRAND, + opacity: d.op, + }), + ]), + ]), + ...(legend && !hasHighlight + ? [box({ gap: "16px" }, [key(1, "operational"), key(0.72, "under construction"), key(0.38, "announced")])] + : []), + ]); +} + +function buildBars(bars: Bar[]): Node { + const max = Math.max(...bars.map((b) => b.value), 1); + const TRACK = 470; + return box({ flexDirection: "column", gap: "13px", width: `${TRACK}px` }, [ + ...bars.map((b) => + box({ flexDirection: "column", gap: "5px" }, [ + box({ justifyContent: "space-between", alignItems: "baseline" }, [ + text(b.label, { fontSize: "14px", color: b.hot ? "#e8e6e3" : SUBTLE }), + text(b.display, { + fontSize: "15px", + fontFamily: "JetBrains Mono", + fontWeight: 700, + color: b.hot ? AMBER : SUBTLE, + }), + ]), + box({ width: `${TRACK}px`, height: "10px", backgroundColor: "#1f1e1c", borderRadius: "2px" }, [ + box({ + width: `${Math.max(3, Math.round((b.value / max) * TRACK))}px`, + height: "10px", + backgroundColor: b.hot ? BRAND : "#4a4641", + borderRadius: "2px", + }), + ]), + ]), + ), + ]); +} + +function buildColumns(columns: Column[], caption?: string): Node { + const max = Math.max(...columns.map((c) => c.value), 1); + const min = Math.min(...columns.map((c) => c.value), 0); + const H = 200; + const span = Math.max(max - min, 1e-9); + return box({ flexDirection: "column", gap: "10px" }, [ + box({ alignItems: "flex-end", gap: "12px", height: `${H}px` }, [ + ...columns.map((c) => { + const h = Math.max(6, Math.round(((c.value - min) / span) * (H - 34)) + 12); + return box({ flexDirection: "column", alignItems: "center", gap: "6px", width: "62px" }, [ + text(c.display ?? String(c.value), { + fontSize: "13px", + fontFamily: "JetBrains Mono", + fontWeight: 700, + color: AMBER, + }), + box({ width: "34px", height: `${h}px`, backgroundColor: BRAND, borderRadius: "2px" }), + text(c.label, { fontSize: "12px", color: MUTED }), + ]); + }), + ]), + ...(caption ? [text(caption, { fontSize: "12px", color: MUTED, justifyContent: "flex-end" })] : []), + ]); +} + +function buildVisual(v: OgVisual): Node | null { + switch (v.kind) { + case "map": + return buildMap(v.dots, v.legend ?? true); + case "bars": + return buildBars(v.bars); + case "columns": + return buildColumns(v.columns, v.caption); + case "none": + return null; + } +} + +// ─── Frame ───────────────────────────────────────────────────────────────── + +/** Build the satori element tree for a card. Pure: no IO, no fonts. */ +export function buildCardTree(card: OgCard): Node { + const visual = buildVisual(card.visual); + const wide = visual === null; + + // Values are mono, so width scales with character count. Step the size down + // for long values ("OpenAI / Oracle") so the stats row cannot run into the + // visual on the right. + const statSize = (value: string) => (value.length <= 7 ? 44 : value.length <= 11 ? 34 : value.length <= 16 ? 26 : 21); + + const stat = (s: OgStat) => + box({ flexDirection: "column", gap: "7px" }, [ + text(s.label, { fontSize: "13px", color: MUTED, textTransform: "uppercase", letterSpacing: "2.5px" }), + text(s.value, { + fontSize: `${statSize(s.value)}px`, + fontFamily: "JetBrains Mono", + fontWeight: 700, + color: AMBER, + lineHeight: "1", + }), + ]); + + return box( + { + width: `${CARD_W}px`, + height: `${CARD_H}px`, + flexDirection: "column", + padding: "46px 54px", + backgroundColor: BG, + fontFamily: "Inter", + color: "#ffffff", + }, + [ + // header + box({ justifyContent: "space-between", alignItems: "center", paddingBottom: "15px", borderBottom: `1px solid ${HAIRLINE}` }, [ + box({ alignItems: "center", gap: "11px" }, [ + box({ width: "6px", height: "25px", backgroundColor: BRAND, borderRadius: "1px" }), + text("GRIDTILT", { + fontSize: "23px", + fontFamily: "JetBrains Mono", + fontWeight: 700, + color: BRAND, + letterSpacing: "3.5px", + }), + ]), + text(`AS OF ${card.asOf ?? "—"}`, { fontSize: "13px", color: MUTED, letterSpacing: "2.5px" }), + ]), + // body + box({ flexGrow: 1, paddingTop: "22px", justifyContent: "space-between" }, [ + box({ flexDirection: "column", width: wide ? "1090px" : "578px", paddingTop: "14px" }, [ + text(card.title, { fontSize: "52px", fontWeight: 700, lineHeight: "1.04", letterSpacing: "-1px" }), + text(card.subtitle, { + fontSize: "20px", + color: SUBTLE, + marginTop: "15px", + lineHeight: "1.35", + width: wide ? "900px" : "530px", + }), + box({ gap: "50px", marginTop: "auto", paddingBottom: "6px" }, card.stats.map(stat)), + ]), + // Short visuals (bars, columns) centre against the text block; the map + // is nearly full height so centring is a no-op for it. + ...(visual ? [box({ alignItems: "center", justifyContent: "flex-end" }, [visual])] : []), + ]), + // footer + box({ justifyContent: "space-between", alignItems: "center", paddingTop: "15px", borderTop: `1px solid ${HAIRLINE}`, marginTop: "16px" }, [ + text(card.source, { fontSize: "14.5px", color: MUTED }), + text("gridtilt.com", { fontSize: "15px", color: "#8a8a85", fontFamily: "JetBrains Mono", fontWeight: 700 }), + ]), + ], + ); +} + +// ─── Render ──────────────────────────────────────────────────────────────── + +interface LoadedFont { + name: string; + data: Buffer; + weight: 400 | 700; + style: "normal"; +} + +let fontCache: LoadedFont[] | null = null; + +function loadFonts(): LoadedFont[] { + if (fontCache) return fontCache; + const dir = join(process.cwd(), "server", "fonts"); + fontCache = [ + { name: "Inter", data: readFileSync(join(dir, "Inter-Regular.ttf")), weight: 400, style: "normal" }, + { name: "Inter", data: readFileSync(join(dir, "Inter-Bold.woff")), weight: 700, style: "normal" }, + { name: "JetBrains Mono", data: readFileSync(join(dir, "JetBrainsMono-Bold.woff")), weight: 700, style: "normal" }, + ]; + return fontCache; +} + +/** Render a card to a 1200x630 PNG. */ +export async function renderOgPng(card: OgCard): Promise { + const satori = (await import("satori")).default; + const { Resvg } = await import("@resvg/resvg-js"); + + const svg = await satori(buildCardTree(card) as unknown as Parameters[0], { + width: CARD_W, + height: CARD_H, + fonts: loadFonts(), + }); + + const resvg = new Resvg(svg, { fitTo: { mode: "width", value: CARD_W } }); + return Buffer.from(resvg.render().asPng()); +} + +/** "2026-06-26" -> "26 JUN 2026". Returns null for missing/unparseable input. */ +export function formatAsOf(iso: string | null | undefined): string | null { + if (!iso) return null; + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + if (!m) return null; + const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + const mon = months[Number(m[2]) - 1]; + if (!mon) return null; + return `${Number(m[3])} ${mon} ${m[1]}`; +} diff --git a/server/routes.ts b/server/routes.ts index 1507e9d..0d6d5b0 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1055,12 +1055,53 @@ interface BacklogDataset { type QueueDataset = BacklogDataset; // ─── OG image renderer (shared by /api/og and the X media upload path) ───── +// +// Layout, fonts and the map projection live in server/og-card.ts. This file +// only gathers the data each template shows. Every card must carry an asOf +// and a source line; see the header of og-card.ts for why. + +import type { OgStat, OgCard, MapDot, Bar } from "./og-card.js"; +import { renderOgPng, formatAsOf } from "./og-card.js"; + +/** Provenance strings. Keep these specific and true; they are a public claim. */ +const SOURCE_CLUSTERS = "company filings, utility records, trade press"; +const SOURCE_QUEUE = "LBNL Queued Up + public ISO interconnection queues"; +const SOURCE_GPU = "neocloud and marketplace on-demand list prices"; +const SOURCE_MARKET = "Yahoo Finance daily closes"; + +/** Reads clusters.json once and reports how many entries carry a source. */ +function clusterSourceLine(clusters: Array<{ sources?: unknown[] }>): string { + const sourced = clusters.filter((c) => Array.isArray(c.sources) && c.sources.length > 0).length; + return `${sourced}/${clusters.length} entries sourced · ${SOURCE_CLUSTERS}`; +} + +/** + * Page OG images (link previews for /stack, /blog, ...). These are page + * furniture rather than a dataset claim, but they still show live numbers, so + * they carry the same as-of and source line as everything else. Defaults to + * today plus the market source; pass `source` when the stats come from a + * curated dataset instead. + */ +function pageCard( + title: string, + subtitle: string, + stats: OgStat[], + opts: { source?: string; visual?: OgCard["visual"] } = {}, +): OgCard { + return { + title, + subtitle, + stats, + asOf: formatAsOf(new Date().toISOString().slice(0, 10)), + source: opts.source ?? SOURCE_MARKET, + visual: opts.visual ?? { kind: "none" }, + }; +} -interface OgStat { label: string; value: string } -interface OgCard { - title: string; - subtitle: string; - stats: OgStat[]; +function clusterDots(clusters: Array): MapDot[] { + return clusters + .filter((c) => typeof c?.location?.lat === "number" && typeof c?.location?.lng === "number") + .map((c) => ({ lat: c.location.lat, lng: c.location.lng, mw: c.plannedPowerMW, status: c.status })); } async function liveIndicesStats(): Promise { @@ -1082,19 +1123,24 @@ async function liveIndicesStats(): Promise { // Per-template OG card content. Pulled from the same data sources the tweet // composers use, so the card and the tweet text stay in sync. -async function ogCardForTemplate(template: string): Promise { +/** Exported for tests and scripts/preview-cards.mjs; routes are the only caller. */ +export async function ogCardForTemplate(template: string): Promise { // ── Daily rotation cards (real data, matched to the tweet copy) ── if (template === "buildout") { const root = JSON.parse(readFileSync(join(process.cwd(), "server", "data", "clusters.json"), "utf-8")); - const m = computeClusterMetrics((root.clusters ?? []) as ClusterLite[]); + const clusters = (root.clusters ?? []) as Array; + const m = computeClusterMetrics(clusters as ClusterLite[]); return { title: "the AI buildout, tracked", - subtitle: "named US AI compute clusters", + subtitle: "every US compute cluster we count carries a public source", stats: [ { label: "Clusters", value: String(m.clusterCount) }, { label: "Planned", value: `${Math.round(m.totalPlannedMW / 1000)} GW` }, { label: "Operators", value: String(m.concentration.operatorCount) }, ], + asOf: formatAsOf(root.lastRefreshed), + source: clusterSourceLine(clusters), + visual: { kind: "map", dots: clusterDots(clusters), legend: true }, }; } if (template === "gpu_rental") { @@ -1102,14 +1148,29 @@ async function ogCardForTemplate(template: string): Promise { const g = computeGpuIndex(root.models ?? [], new Date().toISOString().slice(0, 10)); const by = Object.fromEntries(g.rows.map((r) => [r.model, r])); const px = (n?: number) => (n == null ? "n/a" : Number.isInteger(n) ? `$${n}` : `$${n.toFixed(2)}`); + // Price ladder across every model we track, most expensive first. + const ladder = g.rows + .filter((r) => typeof r.current === "number" && r.current > 0) + .sort((a, b) => (b.current as number) - (a.current as number)) + .slice(0, 6); return { title: "GPU rental prices", - subtitle: "on-demand, blended $/GPU-hr", + subtitle: "on-demand, blended $/GPU-hr across neoclouds and marketplaces", stats: [ { label: "H100", value: px(by.H100?.current) }, { label: "H200", value: px(by.H200?.current) }, { label: "GB200", value: px(by.GB200?.current) }, ], + asOf: formatAsOf(root.lastRefreshed), + source: SOURCE_GPU, + visual: { + kind: "columns", + columns: ladder.map((r) => ({ + label: r.model, + value: r.current as number, + display: px(r.current as number), + })), + }, }; } if (template === "cluster_spotlight") { @@ -1133,38 +1194,85 @@ async function ogCardForTemplate(template: string): Promise { { label: "Operator", value: (c.operator as string) ?? "n/a" }, { label: "Region", value: (c.gridRegion as string) ?? state ?? "n/a" }, ], + asOf: formatAsOf(root.lastRefreshed), + source: clusterSourceLine(clusters), + visual: { + kind: "map", + dots: clusterDots(clusters).map((d) => ({ + ...d, + highlight: d.lat === c.location?.lat && d.lng === c.location?.lng, + })), + }, }; } - return { title: "Compute Frontier", subtitle: "AI superclusters by GPUs and power", stats: await computeFrontierOgStats() }; + return { + title: "Compute Frontier", + subtitle: "AI superclusters by GPUs and power", + stats: await computeFrontierOgStats(), + asOf: formatAsOf(root.lastRefreshed), + source: clusterSourceLine(clusters), + visual: { kind: "map", dots: clusterDots(clusters), legend: true }, + }; } if (template === "grid_backlog") { try { const data = JSON.parse(readFileSync(join(process.cwd(), "server", "data", "interconnection-queue.json"), "utf-8")) as BacklogDataset; const h = data.headline; + const gw = (n: number) => `${n.toLocaleString("en-US")} GW`; + // The 2,290 GW national total is already the lead stat; including it here + // would flatten every regional bar into a sliver. Bars show the breakdown. + const bars: Bar[] = [ + { label: "PJM reopened cycle", value: h.pjmReopenedGW, display: gw(h.pjmReopenedGW) }, + { label: "ERCOT large-load", value: h.ercotLargeLoadGW, display: gw(h.ercotLargeLoadGW), hot: true }, + { label: "Dominion contracted", value: h.dominionContractedGW, display: gw(h.dominionContractedGW) }, + ].filter((b) => typeof b.value === "number" && b.value > 0); return { title: "the grid is the bottleneck", - subtitle: "US interconnection backlog", + subtitle: "power waiting to connect, by queue", stats: [ - { label: "Total queue", value: `${h.queueOverallGW.toLocaleString()} GW` }, + { label: "Total queue", value: gw(h.queueOverallGW) }, { label: "Median wait", value: `${h.medianWaitMonths} mo` }, - { label: "ERCOT large-load", value: `${h.ercotLargeLoadGW} GW` }, + { label: "Withdrawn", value: `${h.historicalWithdrawalPct}%` }, ], + asOf: formatAsOf(data.lastRefreshed), + source: SOURCE_QUEUE, + visual: { kind: "bars", bars }, }; } catch { - return { title: "the grid is the bottleneck", subtitle: "US interconnection backlog", stats: [] }; + return { + title: "the grid is the bottleneck", + subtitle: "US interconnection backlog", + stats: [], + asOf: null, + source: SOURCE_QUEUE, + visual: { kind: "none" }, + }; } } if (template === "power_mix") { const root = JSON.parse(readFileSync(join(process.cwd(), "server", "data", "clusters.json"), "utf-8")); - const m = computeClusterMetrics((root.clusters ?? []) as ClusterLite[]); + const clusters = (root.clusters ?? []) as Array; + const m = computeClusterMetrics(clusters as ClusterLite[]); + const ranked = m.byEnergySource.filter((b) => b.plannedMW > 0).slice(0, 5); return { title: "how the buildout gets power", - subtitle: "planned AI compute by source", + subtitle: "planned AI compute capacity by energy source", stats: m.byEnergySource.slice(0, 3).map((b) => ({ label: b.source, value: `${Math.round(b.plannedMW / 1000)} GW` })), + asOf: formatAsOf(root.lastRefreshed), + source: clusterSourceLine(clusters), + visual: { + kind: "bars", + bars: ranked.map((b, i) => ({ + label: b.source, + value: b.plannedMW, + display: `${Math.round(b.plannedMW / 1000)} GW`, + hot: i === 0, + })), + }, }; } if (template === "tilt_status") { - return { title: "today's market gauges", subtitle: "ai demand · nuclear · grid stress", stats: await liveIndicesStats() }; + return pageCard("today's market gauges", "ai demand · nuclear · grid stress", await liveIndicesStats()); } if (template === "top_movers") { const sd = await getCachedStockData("1D"); @@ -1172,23 +1280,34 @@ async function ogCardForTemplate(template: string): Promise { .filter((s) => s && typeof s.changePercent === "number") .sort((a, b) => Math.abs(b.changePercent) - Math.abs(a.changePercent)) .slice(0, 3); - return { - title: "today's biggest moves", - subtitle: "ai infrastructure equities", - stats: movers.map((s) => ({ label: `$${s.ticker}`, value: `${s.changePercent >= 0 ? "+" : ""}${s.changePercent.toFixed(2)}%` })), - }; + return pageCard( + "today's biggest moves", + "ai infrastructure equities", + movers.map((s) => ({ label: `$${s.ticker}`, value: `${s.changePercent >= 0 ? "+" : ""}${s.changePercent.toFixed(2)}%` })), + { + visual: { + kind: "bars", + bars: movers.map((s) => ({ + label: `$${s.ticker}`, + value: Math.abs(s.changePercent), + display: `${s.changePercent >= 0 ? "+" : ""}${s.changePercent.toFixed(2)}%`, + hot: s.changePercent >= 0, + })), + }, + }, + ); } if (template === "npi_update") { const k = await computeKpis(); - return { - title: "nuclear power index", - subtitle: `${k.npiValue.toFixed(0)} (baseline 100, jan 2024)`, - stats: [ + return pageCard( + "nuclear power index", + `${k.npiValue.toFixed(0)} (baseline 100, jan 2024)`, + [ { label: "VST", value: `${k.constituents.vstPerf >= 1 ? "+" : ""}${((k.constituents.vstPerf - 1) * 100).toFixed(0)}%` }, { label: "CEG", value: `${k.constituents.cegPerf >= 1 ? "+" : ""}${((k.constituents.cegPerf - 1) * 100).toFixed(0)}%` }, { label: "CCJ", value: `${k.constituents.ccjPerf >= 1 ? "+" : ""}${((k.constituents.ccjPerf - 1) * 100).toFixed(0)}%` }, ], - }; + ); } if (template === "queue_update") { try { @@ -1204,96 +1323,26 @@ async function ogCardForTemplate(template: string): Promise { { label: "Median wait", value: `${h.medianWaitMonths} mo` }, { label: "Withdrawal", value: `${h.historicalWithdrawalPct}%` }, ], + asOf: formatAsOf(data.lastRefreshed), + source: SOURCE_QUEUE, + visual: { kind: "none" }, }; } catch { - return { title: "us interconnection backlog", subtitle: "every named power project we can verify", stats: [] }; + return { + title: "us interconnection backlog", + subtitle: "every named power project we can verify", + stats: [], + asOf: null, + source: SOURCE_QUEUE, + visual: { kind: "none" }, + }; } } if (template === "catalyst_preview") { - return { title: "this week's catalysts", subtitle: "earnings · regulatory · policy", stats: await liveIndicesStats() }; + return pageCard("this week's catalysts", "earnings · regulatory · policy", await liveIndicesStats()); } // Fallback - return { title: "gridtilt", subtitle: "ai power infrastructure", stats: await liveIndicesStats() }; -} - -async function renderOgPng(card: OgCard): Promise { - const satori = (await import("satori")).default; - const { Resvg } = await import("@resvg/resvg-js"); - const fontData = readFileSync(join(process.cwd(), "server", "fonts", "Inter-Regular.ttf")); - - const svg = await satori( - { - type: "div", - props: { - style: { - width: "1200px", - height: "630px", - display: "flex", - flexDirection: "column", - justifyContent: "space-between", - padding: "60px", - background: "linear-gradient(135deg, #121110 0%, #1a1917 50%, #121110 100%)", - fontFamily: "Inter, sans-serif", - color: "#ffffff", - }, - children: [ - { - type: "div", - props: { - style: { display: "flex", flexDirection: "column", gap: "16px" }, - children: [ - { - type: "div", - props: { - style: { display: "flex", alignItems: "center", gap: "12px" }, - children: [ - { type: "div", props: { style: { width: "8px", height: "32px", backgroundColor: "#F07800", borderRadius: "4px" } } }, - { type: "div", props: { style: { fontSize: "28px", fontWeight: "700", color: "#F07800", letterSpacing: "2px" }, children: "GRIDTILT" } }, - ], - }, - }, - { type: "div", props: { style: { fontSize: "52px", fontWeight: "800", lineHeight: "1.1", maxWidth: "900px" }, children: card.title } }, - { type: "div", props: { style: { fontSize: "24px", color: "#9ca3af", maxWidth: "900px" }, children: card.subtitle } }, - ], - }, - }, - { - type: "div", - props: { - style: { display: "flex", justifyContent: "space-between", alignItems: "flex-end" }, - children: [ - { - type: "div", - props: { - style: { display: "flex", gap: "40px" }, - children: card.stats.map((s) => ({ - type: "div", - props: { - style: { display: "flex", flexDirection: "column", gap: "4px" }, - children: [ - { type: "div", props: { style: { fontSize: "14px", color: "#6b7280", textTransform: "uppercase", letterSpacing: "2px" }, children: s.label } }, - { type: "div", props: { style: { fontSize: "40px", fontWeight: "700", color: "#F0A500", fontFamily: "monospace" }, children: s.value } }, - ], - }, - })), - }, - }, - { type: "div", props: { style: { fontSize: "18px", color: "#6b7280" }, children: "gridtilt.com" } }, - ], - }, - }, - ], - }, - } as unknown as Parameters[0], - { - width: 1200, - height: 630, - fonts: [{ name: "Inter", data: fontData, weight: 400, style: "normal" as const }], - }, - ); - - const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }); - return Buffer.from(resvg.render().asPng()); + return pageCard("gridtilt", "ai power infrastructure", await liveIndicesStats()); } // ─── X (Twitter) OAuth 1.0a posting client ────────────────────────────────── @@ -3057,44 +3106,44 @@ Preferred-Languages: en card = await ogCardForTemplate(template); } else if (ticker) { const companyInfo = COMPANY_DATABASE[ticker.toUpperCase()]; - card = { - title: companyInfo ? `${companyInfo.name} ($${ticker.toUpperCase()})` : `$${ticker.toUpperCase()}`, - subtitle: companyInfo ? `${companyInfo.primarySegment} Sector` : "AI Power Thesis Analysis", - stats: [{ label: "Sector", value: companyInfo?.primarySegment || "Unknown" }], - }; + card = pageCard( + companyInfo ? `${companyInfo.name} ($${ticker.toUpperCase()})` : `$${ticker.toUpperCase()}`, + companyInfo ? `${companyInfo.primarySegment} Sector` : "AI Power Thesis Analysis", + [{ label: "Sector", value: companyInfo?.primarySegment || "Unknown" }], + ); } else if (page === "stack") { - card = { title: "60+ AI Power Stocks", subtitle: "Live Data Across 8 Sectors", stats: await liveIndicesStats() }; + card = pageCard("60+ AI Power Stocks", "Live Data Across 8 Sectors", await liveIndicesStats()); } else if (page === "power-map") { - card = { title: "US AI Data Center Map", subtitle: "Filter by operator, region, and capacity (\u2265 400 MW)", stats: await liveIndicesStats() }; + card = pageCard("US AI Data Center Map", "Filter by operator, region, and capacity (\u2265 400 MW)", await liveIndicesStats()); } else if (page === "compute-frontier" && name) { - card = { title: name, subtitle: "AI Supercluster \u00b7 GridTilt", stats: await computeFrontierOgStats() }; + card = pageCard(name, "AI Supercluster \u00b7 GridTilt", await computeFrontierOgStats()); } else if (page === "compute-frontier") { - card = { title: "Compute Frontier", subtitle: "AI superclusters by GPUs and power", stats: await computeFrontierOgStats() }; + card = pageCard("Compute Frontier", "AI superclusters by GPUs and power", await computeFrontierOgStats()); } else if (page === "supply-chain") { - card = { title: "AI Power Supply Chain", subtitle: "5 systems, 20 sub-systems, silicon to substation", stats: await liveIndicesStats() }; + card = pageCard("AI Power Supply Chain", "5 systems, 20 sub-systems, silicon to substation", await liveIndicesStats()); } else if (page === "queue") { card = await ogCardForTemplate("queue_update"); } else if (page === "trade") { - card = { title: "AI Power Scenario Calculator", subtitle: "Model demand, capex, and LPT requirements through 2030", stats: await liveIndicesStats() }; + card = pageCard("AI Power Scenario Calculator", "Model demand, capex, and LPT requirements through 2030", await liveIndicesStats()); } else if (page === "portfolio") { - card = { title: "AI Power Thesis Score", subtitle: "Rate any portfolio against the AI power buildout", stats: await liveIndicesStats() }; + card = pageCard("AI Power Thesis Score", "Rate any portfolio against the AI power buildout", await liveIndicesStats()); } else if (page === "catalysts") { - card = { title: "Catalyst Calendar", subtitle: "Earnings, policy, and regulatory events for AI power", stats: await liveIndicesStats() }; + card = pageCard("Catalyst Calendar", "Earnings, policy, and regulatory events for AI power", await liveIndicesStats()); } else if (page === "blog" && name) { - card = { title: name, subtitle: "GridTilt Analysis", stats: await liveIndicesStats() }; + card = pageCard(name, "GridTilt Analysis", await liveIndicesStats()); } else if (page === "blog") { - card = { title: "GridTilt Analysis", subtitle: "Research on the AI power infrastructure thesis", stats: await liveIndicesStats() }; + card = pageCard("GridTilt Analysis", "Research on the AI power infrastructure thesis", await liveIndicesStats()); } else if (page === "subscribe") { - card = { title: "Get the Tilt", subtitle: "Weekly AI power market intel, in your inbox", stats: await liveIndicesStats() }; + card = pageCard("Get the Tilt", "Weekly AI power market intel, in your inbox", await liveIndicesStats()); } else if (page === "sector" && name) { - card = { title: `${name} Sector`, subtitle: "AI Power Infrastructure Stocks", stats: await liveIndicesStats() }; + card = pageCard(`${name} Sector`, "AI Power Infrastructure Stocks", await liveIndicesStats()); } else if (page === "region" && name) { - card = { title: `${name} Grid Region`, subtitle: "AI Data Center Locations", stats: await liveIndicesStats() }; + card = pageCard(`${name} Grid Region`, "AI Data Center Locations", await liveIndicesStats()); } else if (page === "operator" && name) { - card = { title: `${name} AI Data Centers`, subtitle: "Locations and Capacity", stats: await liveIndicesStats() }; + card = pageCard(`${name} AI Data Centers`, "Locations and Capacity", await liveIndicesStats()); } else { // home (default) - card = { title: "The grid is tilting.", subtitle: "AI power infrastructure dashboard", stats: await liveIndicesStats() }; + card = pageCard("The grid is tilting.", "AI power infrastructure dashboard", await liveIndicesStats()); } const png = await renderOgPng(card); diff --git a/server/us-map.ts b/server/us-map.ts new file mode 100644 index 0000000..e8ed458 --- /dev/null +++ b/server/us-map.ts @@ -0,0 +1,29 @@ +// GENERATED by scripts/generate-map-path.mjs. Do not edit by hand. +// Continental US outline for the daily social card, projected with +// geoAlbers().fitSize([478, 290], continentalUS). + +export const MAP_W = 478; +export const MAP_H = 290; + +/** geoAlbers scale/translate, kept for reference and for the parity test. */ +export const MAP_SCALE = 647.646481040628; +export const MAP_TRANSLATE: [number, number] = [238.83895328347967, 144.76400425965065]; + +/** + * Closed-form Albers constants, solved against d3's own output and verified to + * better than 1e-6 px across the continental US. The server projects cluster + * coordinates with these instead of importing d3-geo, which is ESM-only and + * would throw ERR_REQUIRE_ESM from the CJS server bundle on older Node. + */ +export const ALBERS = { + n: 0.6028370046288243, + c: 1.351221325417899, + rho0: 1.92825000937014, + lambda0Deg: 96, + k: 647.6464810406277, + dx: 244.0808685128568, + dy: 563.2488284924962, +} as const; + +/** 49 continental states + DC, one concatenated path. */ +export const US_PATH = "M323.299,183.383L339.329,181.961L342.016,191.522L345.925,205.573L347.368,208.604L348.607,210.277L348.324,211.438L349.489,211.992L348.05,213.6L348.266,215.016L347.656,217.027L348.985,220.316L348.647,223.358L350.053,226.321L345.329,226.921L325.114,228.937L324.929,230.45L327.289,232.42L327.086,234.307L327.913,235.167L326.629,236.964L325.333,237.453L322.728,235.811L322.225,233.052L321.458,232.805L320.734,234.983L320.584,237.049L318.041,236.697L315.925,219.481L316.353,197.764L316.64,185.218L315.59,184.114Z M127.837,156.193L118.955,220.129L99.674,217.255L89.271,211.241L66.854,197.946L68.22,195.615L70.132,195.551L70.833,194.673L70.736,192.495L69.483,192.183L69.706,187.907L71.882,186.686L72.459,185.084L72.628,182.384L74.113,180.642L75.703,180.19L77.085,178.933L75.603,176.992L74.939,173.809L73.818,171.678L74.098,170.27L74.944,168.846L75.16,166.724L74.85,164.434L75.621,157.771L78.962,157.986L79.769,159.544L80.658,159.653L81.949,157.929L83.683,148.906L114.958,154.251Z M257.817,169.759L296.682,168.313L297.606,170.448L296.308,171.905L295.001,174.112L300.845,173.746L300.687,175.885L299.387,176.596L299.2,178.36L297.623,180.337L297.943,183.071L297.163,185.12L296.279,185.487L296.891,186.451L295.485,187.41L294.944,189.38L294.014,189.934L294.294,192.169L292.659,192.886L292.751,193.63L290.925,195.604L291.551,196.82L289.966,198.715L288.663,202.278L290.378,203.688L289.606,204.665L290.242,207.003L289.601,208.596L266.602,209.283L262.528,209.377L262.404,203.333L261.062,202.86L259.229,203.455L258.239,202.411L258.419,182.372L256.535,169.779Z M20.052,74.765L26.913,76.688L37.648,79.859L45.996,82.061L41.114,101.17L37.462,115.135L45.897,127.821L54.061,140.098L60.641,149.97L65.456,157.21L74.098,170.27L73.818,171.678L74.939,173.809L75.603,176.992L77.085,178.933L75.703,180.19L74.113,180.642L72.628,182.384L72.459,185.084L71.882,186.686L69.706,187.907L69.483,192.183L70.736,192.495L70.833,194.673L70.132,195.551L68.22,195.615L55.727,194.113L45.533,192.889L44.751,191.18L45.225,188.861L45.144,185.967L44.285,183.724L42.054,180.392L39.004,177.048L38.149,177.549L36.858,176.791L37.319,175.747L36.274,173.122L34.028,173.089L30.677,170.526L30.467,169.059L28.37,166.794L25.507,166.129L23.34,164.796L20.273,164.317L19.09,162.455L20.247,159.468L19.84,158.844L20.757,156.761L18.864,154.512L19.366,152.254L18.529,151.9L17.605,149.579L16.71,148.883L16.606,147.559L14.5,142.119L13.296,140.357L13.957,136.713L14.536,137.199L15.739,135.264L15.048,132.994L13.471,132.811L11.987,130.507L11.676,128.923L12.194,127.641L11.71,125.617L12.536,122.732L14.12,123.184L14.617,118.971L13.801,119.193L13.094,121.136L11.299,121.076L9.738,118.996L10.167,116.321L9.472,113.903L8.14,112.142L7.648,110.431L6.017,106.878L6.68,106.163L6.64,102.17L7.751,100.285L7.947,97.016L6.739,93.385L5.189,91.082L5.393,88.988L8.906,84.84L9.797,83.35L9.781,81.974L11.531,78.92L11.781,75.735L11.123,74.751L12.203,72.473Z M143.503,112.184L161.911,114.352L176.033,115.615L192.899,116.869L192.171,128.274L190.085,162.527L181.54,161.877L169.625,160.976L147.116,158.694L142.212,158.054L127.837,156.193L129.659,143.003L129.687,141.744L131.061,132.153L134.032,110.996Z M433.51,83.729L443.692,81.344L443.739,81.525L445.436,88.044L445.212,89.198L444.451,89.202L441.092,90.964L436.778,91.789L435.312,93.757L433.454,94.725L431.42,96.564L430.529,95.429L432.261,93.729L431.458,92.96L429.976,84.463Z M419.82,113.07L419.332,114.585L418.615,115.509L419.186,117.239L420.797,118.614L421.726,121.29L424.177,123.756L425.044,123.691L426.278,127.45L420.719,128.548L416.887,114.669L418.05,113.138Z M408.006,125.018L409.304,125.902L408.417,127.294L407.441,125.833Z M345.329,226.921L350.053,226.321L351.751,229.436L365.02,228.528L377.46,227.674L378.284,229.998L379.436,229.766L379.521,227.433L378.844,225.4L379.608,224.408L381.95,224.998L384.639,225.027L385.967,229.78L388.032,235.042L391.984,241.75L397.563,248.674L397.007,249.328L397.801,252.826L400.295,256.478L404.516,263.951L405.539,266.336L405.916,268.891L406.355,278.215L405.441,278.555L404.913,281.563L405.399,282.413L403.784,284.79L402.922,284.436L401.158,285.591L397.988,286.594L396.854,285.596L397.035,283.832L393.982,279.286L392.063,278.652L390.609,279.556L388.974,277.012L388.304,274.816L385.832,272.638L385.093,271.07L385.073,268.649L383.856,268.393L384.336,269.752L383.344,270.27L379.29,264.7L377.816,263.354L380.18,258.472L378.256,258.997L377.147,260.585L375.533,258.57L376.411,252.215L376.017,247.092L374.663,246.033L374.056,244.432L372.129,244.325L369.567,241.927L367.656,241.059L367.328,239.416L366.021,238.964L364.768,237.255L360.733,235.275L357.563,236.233L357.935,237.935L356.833,237.758L353.034,240.335L348.767,241.326L348.734,240.083L347.555,238.719L342.14,235.943L338.382,234.902L335.11,234.857L332.428,235.368L326.629,236.964L327.913,235.167L327.086,234.307L327.289,232.42L324.929,230.45L325.114,228.937Z M362.064,178.973L360.445,181.65L360.453,182.845L363.836,184.842L364.763,184.525L366.514,186.801L366.998,188.056L368.792,190.197L371.117,191.312L372.637,193.233L375.403,194.778L375.494,196.088L377.436,197.939L380.196,199.284L381.093,201.036L381.566,203.421L382.972,204.021L384.934,206.801L385.286,208.698L387.608,209.337L385.887,213.515L385.782,215.545L385.063,217.419L385.237,219.216L384.318,220.176L384.639,225.027L381.95,224.998L379.608,224.408L378.844,225.4L379.521,227.433L379.436,229.766L378.284,229.998L377.46,227.674L365.02,228.528L351.751,229.436L350.053,226.321L348.647,223.358L348.985,220.316L347.656,217.027L348.266,215.016L348.05,213.6L349.489,211.992L348.324,211.438L348.607,210.277L347.368,208.604L345.925,205.573L342.016,191.522L339.329,181.961L351.06,180.53L357.454,179.779Z M94.66,12.277L92.261,23.481L94.027,27.06L93.435,30.077L94.603,31.705L96.033,32.505L96.037,33.323L98.258,36.857L98.347,38.133L100.122,39.682L100.061,40.425L102.291,40.801L100.392,44.707L99.668,47.272L100.138,49.129L98.501,50.067L98.825,51.329L98.202,52.468L99.636,53.948L101.847,52.801L102.846,51.732L104.041,53.094L103.655,53.966L104.02,56.241L104.881,58.735L105.678,59.703L105.238,61.828L105.974,62.911L107.591,63.333L107.976,67.124L108.777,67.899L109.787,67.008L112.249,67.506L114.241,66.844L115.231,67.581L117.18,67.409L117.468,68.086L119.146,67.926L121.35,65.899L122.29,68.063L123.537,69.458L119.103,97.231L109.899,95.769L94.482,92.964L70.123,87.822L74.686,67.534L76.541,64.129L76.097,63.075L74.629,62.553L74.412,60.912L76.89,57.337L77.785,57.156L78.962,55.704L79.063,54.647L80.254,53.515L81.151,51.682L83.48,48.771L83.177,47.121L81.536,45.935L80.901,43.83L81.291,41.89L80.719,39.736L81.048,38.861L84.307,24.187L87.43,10.69Z M288.436,100.097L303.744,99.27L311.881,98.609L311.797,100.806L313.29,103.241L314.98,107.286L317.33,134.098L316.566,136.236L317.873,138.567L318.194,140.547L317.242,142.263L317.087,143.783L315.719,146.349L314.728,146.622L315.09,148.035L314.465,148.652L314.16,151.376L314.513,152.099L313.488,153.815L314.477,155.741L310.924,157.096L310.678,158.307L311.624,159.737L310.577,160.761L307.179,159.266L306.165,159.466L304.945,161.561L305.43,162.152L304.004,162.129L301.779,159.025L302.458,158.227L301.579,156.094L301.46,154.347L298.446,152.105L297.496,152.416L296.437,150.98L293.696,148.83L293.636,147.017L294.944,143.994L294.64,142.948L295.414,141.523L294.126,140.788L292.192,140.402L291.253,141.52L290.547,140.871L289.768,137.159L286.749,134.88L283.958,132.08L282.708,128.637L282.505,126.333L283.175,124.675L283.216,122.61L285.527,121.244L285.669,119.486L286.723,118.306L286.756,116.303L285.286,114.755L285.735,112.733L288.97,111.995L291.504,110.411L291.674,108.651L292.727,107.837L292.913,105.639L292.597,104.222L290.664,103.214L290.368,102.045Z M327.704,105.481L337.544,104.379L337.632,105.122L339.277,118.629L341.008,134.468L340.404,135.043L341.481,138.063L339.969,138.303L338.475,139.545L336.185,139.301L336.53,141.527L335.155,142.623L334.738,144.115L333.268,144.84L332.759,147.787L331.829,148.639L329.749,147.787L329.279,146.516L327.504,148.141L327.773,149.37L325.842,150.001L325.156,148.937L323.049,150.27L322.437,151.52L320.065,150.044L318.943,150.522L318.1,149.781L317.449,150.654L315.243,150.971L314.513,152.099L314.16,151.376L314.465,148.652L315.09,148.035L314.728,146.622L315.719,146.349L317.087,143.783L317.242,142.263L318.194,140.547L317.873,138.567L316.566,136.236L317.33,134.098L314.98,107.286L315.87,107.958L318.43,107.722L320.772,106.183Z M281.865,89.159L283.115,89.097L283.289,90.77L284.555,91.827L283.638,93.245L284.038,95.78L284.762,97.551L287.768,98.699L288.436,100.097L290.368,102.045L290.664,103.214L292.597,104.222L292.913,105.639L292.727,107.837L291.674,108.651L291.504,110.411L288.97,111.995L285.735,112.733L285.286,114.755L286.756,116.303L286.723,118.306L285.669,119.486L285.527,121.244L283.216,122.61L283.175,124.675L282.224,124.345L280.393,122.116L279.512,122.218L267.414,122.97L255.719,123.348L246.069,123.242L245.088,121.745L245.548,118.81L244.712,116.252L244.755,113.382L243.283,112.383L243.055,110.823L243.561,109.452L243.014,107.456L241.873,106.705L240.391,101.589L238.865,99.088L239.598,97.411L239.881,95.171L240.515,94.365L239.487,93.24L239.768,91.25L239.326,90.314L240.4,90.071Z M193.438,128.354L250.033,129.906L250.887,130.962L253.674,131.808L251.774,135.141L252.873,136.254L254.28,138.926L256.141,139.463L256.452,164.092L207.545,163.396L190.085,162.527L192.171,128.274Z M349.379,137.279L351.525,138.578L352.795,137.592L356.186,138.02L356.982,136.776L358.162,136.234L358.808,138.101L359.848,138.271L361.255,139.65L361.499,143.089L362.86,145.105L364.489,146.576L365.11,147.813L366.982,148.866L368.163,148.88L365.234,152.406L362.233,154.539L362.355,155.405L361.15,156.331L361.19,157.336L359.549,158.003L359.135,159.322L354.572,161.569L354.449,161.775L346.813,162.608L340.12,163.015L338.373,163.336L328.442,163.941L317.197,165.234L315.194,164.902L315.513,166.945L304.347,167.681L303.274,167.882L303.665,166.414L305.027,166.819L305.43,162.152L304.945,161.561L306.165,159.466L307.179,159.266L310.577,160.761L311.624,159.737L310.678,158.307L310.924,157.096L314.477,155.741L313.488,153.815L314.513,152.099L315.243,150.971L317.449,150.654L318.1,149.781L318.943,150.522L320.065,150.044L322.437,151.52L323.049,150.27L325.156,148.937L325.842,150.001L327.773,149.37L327.504,148.141L329.279,146.516L329.749,147.787L331.829,148.639L332.759,147.787L333.268,144.84L334.738,144.115L335.155,142.623L336.53,141.527L336.185,139.301L338.475,139.545L339.969,138.303L341.481,138.063L340.404,135.043L341.008,134.468L344.315,134.07L346.356,136.212L346.621,137.187Z M266.602,209.283L289.601,208.596L290.544,209.857L289.899,210.389L289.913,212.696L291.386,214.054L291.771,217.399L290.76,220.069L288.595,221.797L288.143,224.371L287.188,224.168L287.227,228.335L286.074,228.513L286.863,230.715L286.214,231.553L304.437,230.534L303.728,234.313L305.483,236.685L305.979,238.517L307.231,239.613L304.453,241.352L304.311,242.479L306.714,243.065L307.608,241.203L309.764,242.854L309.704,244.287L308.568,244.987L306.332,244.581L306.67,245.613L306.026,247.27L308.004,248.564L311.049,248.785L312.248,250.373L313.129,250.557L311.702,252.583L309.946,252.336L308.351,250.463L304.669,249.654L304.545,247.739L302.809,248.47L303.017,250.07L302.301,251.602L301.019,251.929L299.947,250.32L297.679,250.391L296.967,252.166L295.481,252.744L293.803,251.782L292.5,251.726L291.118,249.008L288.85,247.88L287.998,248.106L287.029,245.796L284.466,246.22L284.351,244.799L281.886,246.267L282.251,247.305L280.355,248.373L277.323,247.99L273.777,246.56L271.286,245.955L265.984,246.652L265.295,247.102L264.411,246.006L266.622,241.926L265.822,239.774L266.487,238.58L266.13,237.037L267.058,235.835L267.991,232.892L267.77,230.475L265.024,225.943L264.915,223.458L262.768,221.016L262.528,209.377Z M449.421,67.632L448.254,67.111L448.09,65.994L446.494,65.073L442.102,50.812L439.824,43.849L442.735,41.457L441.979,40.829L442.776,38.815L443.855,37.683L443.424,37.096L444.35,35.749L443.448,33.946L443.399,30.882L444.289,29.608L443.892,26.451L447.273,16.519L448.711,16.486L449.377,18.471L450.559,18.895L452.602,17.009L454.1,16.563L454.921,15.481L457.5,16.566L459.146,17.61L462.929,29.685L463.65,32.634L466.494,32.6L466.505,33.957L467.536,34.935L467.325,36.167L468.921,37.488L470.227,36.692L472.811,40.104L471.758,42.32L470.633,41.956L470.245,43.376L469.062,43.417L469.246,44.464L467.779,44.785L466.207,47.795L465.173,46.357L464.336,46.479L465.235,48.089L463.708,49.455L462.876,48.213L462.242,49.179L460.251,49.767L459.736,48.171L458.657,48.875L459.199,49.946L458.791,52.653L459.195,53.247L458.126,54.98L456.538,54.92L456.077,56.539L454.93,57.06L454.267,58.475L452.999,58.574L452.32,57.473L451.096,59.88L451.902,60.946L450.727,61.724L450.923,62.767L449.802,64.431Z M419.352,134.79L419.163,134.83L418.927,134.88ZM385.683,120.776L416.887,114.669L420.719,128.548L426.278,127.45L425.634,132.526L424.354,133.001L422.141,134.189L420.403,135.076L420.056,133.232L419.191,132.65L420.01,131.643L418.3,130.092L418.046,130.977L416.219,131.173L415.183,129.282L415.746,129.164L415.239,126.524L415.581,125.366L414.086,121.976L414.577,119.7L415.936,119.028L415.711,116.839L414.699,117.311L414.887,118.421L412.957,120.297L412.575,121.718L413.112,124.99L412.588,126.695L413.497,129.188L414.995,130.727L415.136,132.103L416.125,133.301L415.94,134.298L413.588,132.936L410.582,132.659L409.386,131.116L407.982,132.419L407.1,131.193L408.025,129.222L408.417,127.294L409.304,125.902L408.006,125.018L407.441,125.833L406.11,125.016L404.226,124.817L403.899,123.162L402.786,122.425L401.446,122.496L399.862,119.557L398.424,119.834L396.787,119.065L396.161,120.074L394.756,120.274L394.66,121.563L392.002,121.229L390.628,123.194L389.443,123.027L388.104,125.234L386.606,126.578Z M448.231,69.955L449.063,69.923L449.891,71.763L449.965,73.421L449.086,75.208L449.496,76.905L451.318,76.67L452.849,78.126L453.051,79.558L454.031,79.677L454.466,80.916L457.018,81.376L459.337,79.627L459.185,81.289L455.702,83.681L454.3,84.13L453.178,83.337L451.93,83.998L452.097,84.793L450.717,85.681L449.563,83.859L449.245,83.556L448.195,82.997L447.065,80.522L445.873,80.837L443.739,81.525L443.692,81.344L433.51,83.729L429.976,84.463L429.697,84.081L429.876,76.343L436.413,74.912L445.785,72.867L446.386,71.611Z M348.819,103.278L337.632,105.122L337.544,104.379L327.704,105.481L320.772,106.183L322.312,104.528L323.195,101.871L324.081,100.215L324.627,97.967L324.759,94.822L324.234,91.495L321.159,85.178L321.664,82.628L320.835,79.714L322.376,76.499L322.51,73.926L322.108,72.596L323.36,71.904L323.341,70.036L325.334,69.325L326.677,67.121L326.992,71.203L327.841,71.297L328.66,69.147L328.317,65.693L328.869,64.758L330.966,63.957L329.999,61.638L331.183,59.445L332.928,59.114L335.059,60.166L337.015,60.111L338.162,61.59L339.641,61.527L342.288,62.682L343.142,62.507L344.8,64.724L343.89,66.158L345.152,67.679L345.8,69.531L345.882,73.779L344.401,75.041L344.289,77.248L342.45,78.239L341.723,80.964L342.247,81.9L344.313,82.573L345.63,80.958L347.007,77.826L349.624,76.34L351.106,77.012L352.167,78.494L353.671,83.053L354.138,85.375L355.434,88.082L355.168,92.272L353.91,93.08L353.656,91.606L352.821,92.163L352.26,95.701L350.771,97.227L350.626,99.889L348.832,102.332ZM326.75,60.261L327.029,61.599L326.023,61.96L326.244,60.006ZM311.034,69.047L309.715,67.981L310.311,66.312L308.427,66.227L309.03,64.621L308.945,62.641L307.192,61.425L306.17,60.083L302.731,59.245L301.729,59.695L298.257,58.282L290.054,56.544L289.083,54.686L287.593,54.095L290.545,52.741L291.819,51.3L295.177,50.519L297.262,48.702L298.273,48.567L299.031,47.336L301.309,45.494L302.417,43.986L304.149,42.92L305.938,43.573L303.189,47.26L302.564,48.485L302.773,50.571L304.127,48.853L306.814,48.876L308.985,49.801L311.128,52.706L312.2,53.166L314.112,52.491L314.644,53.123L316.638,53.302L320.542,50.302L322.678,49.829L325.573,49.638L327.424,48.561L328.897,48.33L329.556,51.422L331.135,51.672L332.606,50.999L333.331,51.658L334.238,50.615L336.448,50.03L336.983,53.946L338.216,55.472L339.808,55.704L339.836,54.579L341.329,54.384L342.293,55.441L341.723,56.39L337.353,56.204L335.362,56.95L332.974,55.87L332.479,57.174L332.904,58.182L331.889,58.054L330.253,56.689L327.626,56.056L326.337,56.138L325.258,57.749L323.241,58.34L320.98,58.266L320.142,58.974L320.053,60.226L317.706,61.519L317.689,60.028L316.585,59.826L316.304,61.407L314.502,61.641L313.747,62.395L312.783,65.157L310.833,68.754ZM298.921,37.476L297.107,39.028L296.118,39.284L296.118,38.114L300.706,35.122L299.934,37.029Z M275.076,53.182L274.46,52.714L272.922,53.701L273.182,60.365L272.735,61.063L270.53,62.072L268.8,64.545L268.723,66.157L269.637,66.251L270.684,67.639L269.831,69.404L270.071,71.317L269.644,75.488L271.823,77.461L273.501,77.584L274.386,78.792L276.905,79.931L277.367,81.403L279.754,83.223L281.06,83.597L282.736,86.004L282.603,87.815L283.115,89.097L281.865,89.159L240.4,90.071L240.497,69.71L238.678,68.399L237.307,66.221L239.488,63.824L239.669,62.527L239.395,58.014L238.461,56.834L237.841,54.361L237.993,51.34L237.7,50.845L237.511,43.64L236.04,39.815L235.481,37.66L235.286,33.12L235.801,31.595L234.855,28.035L250.441,28.066L250.403,23.79L251.876,23.897L252.868,24.741L253.93,30.535L254.724,31.197L257.205,31.339L257.505,31.885L260.404,32.07L260.764,33.287L263.241,32.919L263.228,32.429L265.154,31.762L266.857,31.955L268.832,32.809L269.411,33.954L270.526,33.791L271.658,36.264L272.117,35.203L274.007,34.636L274.383,35.664L276.66,36.302L276.704,37.282L277.866,38.028L280.138,37.488L281.456,36.315L283.291,35.542L284.047,37.159L285.318,36.719L286.879,36.997L288.654,36.644L290.784,37.924L292.727,37.55L292.602,38.174L290.147,39.75L286.658,41.071L284.42,42.368L281.252,45.432L279.907,47.286L277.804,49.421L274.442,52.282Z M313.134,184.313L315.59,184.114L316.64,185.218L316.353,197.764L315.925,219.481L318.041,236.697L317.017,237.276L314.658,237.211L313.645,236.541L311.342,237.209L308.188,238.739L307.231,239.613L305.979,238.517L305.483,236.685L303.728,234.313L304.437,230.534L286.214,231.553L286.863,230.715L286.074,228.513L287.227,228.335L287.188,224.168L288.143,224.371L288.595,221.797L290.76,220.069L291.771,217.399L291.386,214.054L289.913,212.696L289.899,210.389L290.544,209.857L289.601,208.596L290.242,207.003L289.606,204.665L290.378,203.688L288.663,202.278L289.966,198.715L291.551,196.82L290.925,195.604L292.751,193.63L292.659,192.886L294.294,192.169L294.014,189.934L294.944,189.38L295.485,187.41L296.891,186.451L296.279,185.487Z M279.512,122.218L280.393,122.116L282.224,124.345L283.175,124.675L282.505,126.333L282.708,128.637L283.958,132.08L286.749,134.88L289.768,137.159L290.547,140.871L291.253,141.52L292.192,140.402L294.126,140.788L295.414,141.523L294.64,142.948L294.944,143.994L293.636,147.017L293.696,148.83L296.437,150.98L297.496,152.416L298.446,152.105L301.46,154.347L301.579,156.094L302.458,158.227L301.779,159.025L304.004,162.129L305.43,162.152L305.027,166.819L303.665,166.414L303.274,167.882L302.684,167.923L302.192,167.956L302.432,170.759L300.845,173.746L295.001,174.112L296.308,171.905L297.606,170.448L296.682,168.313L257.817,169.759L256.535,169.779L256.452,164.092L256.141,139.463L254.28,138.926L252.873,136.254L251.774,135.141L253.674,131.808L250.887,130.962L250.033,129.906L247.904,126.92L246.069,123.242L255.719,123.348L267.414,122.97Z M183.725,25.538L182.689,38.244L180.823,59.736L179.96,70.413L179.83,70.402L165.081,68.941L139.999,65.891L124.433,63.571L123.537,69.458L122.29,68.063L121.35,65.899L119.146,67.926L117.468,68.086L117.18,67.409L115.231,67.581L114.241,66.844L112.249,67.506L109.787,67.008L108.777,67.899L107.976,67.124L107.591,63.333L105.974,62.911L105.238,61.828L105.678,59.703L104.881,58.735L104.02,56.241L103.655,53.966L104.041,53.094L102.846,51.732L101.847,52.801L99.636,53.948L98.202,52.468L98.825,51.329L98.501,50.067L100.138,49.129L99.668,47.272L100.392,44.707L102.291,40.801L100.061,40.425L100.122,39.682L98.347,38.133L98.258,36.857L96.037,33.323L96.033,32.505L94.603,31.705L93.435,30.077L94.027,27.06L92.261,23.481L94.66,12.277L128.186,18.683L143.399,20.957Z M183.928,93.424L197.849,94.436L223.535,95.535L223.791,96.102L227.983,98.255L228.998,97.154L230.166,97.425L234.047,97.484L238.364,99.645L238.896,101.269L240.391,101.589L241.873,106.705L243.014,107.456L243.561,109.452L243.055,110.823L243.283,112.383L244.755,113.382L244.712,116.252L245.548,118.81L245.088,121.745L246.069,123.242L247.904,126.92L250.033,129.906L193.438,128.354L192.171,128.274L192.899,116.869L176.033,115.615L177.959,92.94Z M70.123,87.822L94.482,92.964L83.683,148.906L81.949,157.929L80.658,159.653L79.769,159.544L78.962,157.986L75.621,157.771L74.85,164.434L75.16,166.724L74.944,168.846L74.098,170.27L65.456,157.21L60.641,149.97L54.061,140.098L45.897,127.821L37.462,115.135L41.114,101.17L45.996,82.061L56.528,84.766Z M439.824,43.849L442.102,50.812L446.494,65.073L448.09,65.994L448.254,67.111L449.421,67.632L449.063,69.923L448.231,69.955L446.386,71.611L445.785,72.867L436.413,74.912L435.499,74.244L435.176,72.591L435.722,71.811L435.238,70.264L434.669,65.467L435.499,62.945L435.556,60.3L436.011,59.221L435.27,56.526L437.611,54.828L438.401,52.632L437.206,51.021L437.792,48.943L437.423,47.886L437.775,44.781L439.622,44.609Z M426.242,95.991L429.362,96.925L429.128,100.38L427.94,101.429L427.61,103.301L430.083,103.62L430.566,104.854L430.962,111.05L429.372,116.104L427.955,117.777L427.063,120.929L425.86,119.279L423.197,118.915L419.648,117.138L419.103,115.53L419.004,115.296L419.332,114.585L419.82,113.07L421.87,111.656L421.818,110.772L423.972,108.366L424.163,107.298L421.281,105.579L420.876,104.198L419.748,104.066L419.372,102.806L420.207,100.572L419.269,99.566L420.827,96.786L421.002,95.466L421.902,94.43Z M142.212,158.054L147.116,158.694L169.625,160.976L181.54,161.877L181.121,167.55L180.777,167.524L178.628,195.794L177.568,207.28L176.722,218.641L143.012,215.496L142.627,216.579L143.575,217.999L127.606,216.08L126.945,221.197L118.955,220.129L127.837,156.193Z M423.186,51.492L423.828,53.761L423.899,55.914L425.11,57.728L425.409,59.891L425.047,62.344L426.47,65.196L426.257,66.208L427.956,67.715L429.552,75.397L429.876,76.343L429.697,84.081L429.976,84.463L431.458,92.96L432.261,93.729L430.529,95.429L431.42,96.564L435.156,96.623L435.713,95.78L438.729,95.034L440.166,94.288L442.235,91.897L442.738,93.122L444.179,93.333L441.583,95.734L435.821,99.739L433.267,100.872L431.47,101.174L430.284,102.033L429.128,100.38L429.362,96.925L426.242,95.991L421.902,94.43L421.362,93.721L420.091,93.945L418.186,92.391L417.997,90.707L416.728,89.649L416.116,89.849L414.886,88.524L378.851,95.604L378.364,92.779L378.332,92.594L382.78,88.522L383.306,86.845L384.719,85.513L383.752,83.727L383.02,83.48L381.981,80.505L386.463,78.356L390.655,77.626L392.385,77.608L394.428,78.477L395.476,77.762L398.961,77.126L400.907,75.903L402.725,73.429L404.155,73.065L403.556,70.014L403.923,68.094L401.887,67.254L401.983,65.774L404.739,63.221L405.549,61.332L408.516,56.808L411.641,54.139L416.993,53.224Z M378.611,158.529L384.718,157.723L391.674,156.597L423.804,150.202L425.78,154.443L423.219,154.545L422.997,155.168L420.133,156.474L419.824,157.177L417.87,157.774L418.131,158.549L420.447,157.494L420.89,157.912L423.445,156.797L424.541,157.649L426.052,156.877L427.217,159.438L426.958,160.836L425.911,161.189L424.249,164.418L421.285,165.168L421.189,167.166L422.863,168.794L423.974,168.943L422.702,172.463L420.994,172.434L418.187,173.326L416.342,174.399L413.64,177.162L411.699,180.465L411.083,184.268L409.027,183.835L405.856,185.192L393.602,176.362L383.38,177.883L383.32,176.565L381.623,174.877L380.789,175.705L380.553,174.541L369.309,175.682L366.889,176.472L365.098,177.797L362.064,178.973L357.454,179.779L351.06,180.53L350.98,177.834L352.74,177.363L353.2,175.416L355.216,173.458L357.683,173.072L359.675,171.041L361.909,170.106L363.526,167.295L364.65,166.38L365.053,167.459L368.275,164.785L369.956,164.98L370.769,162.711L372.347,161.905L372.334,159.189Z M234.855,28.035L235.801,31.595L235.286,33.12L235.481,37.66L236.04,39.815L237.511,43.64L237.7,50.845L237.993,51.34L237.841,54.361L238.461,56.834L239.395,58.014L239.669,62.527L180.823,59.736L182.689,38.244L183.725,25.538Z M372.675,96.898L375.155,111.984L374.014,112.803L374.813,113.938L375.049,115.672L374.334,118.573L374.212,122.833L371.474,127.001L370.474,127.663L369.422,127.067L368.685,128.824L367.69,128.913L366.935,131.239L367.366,132.502L366.628,133.687L365.097,132.145L363.892,135.102L364.571,136.772L363.618,137.543L363.501,139.076L361.255,139.65L359.848,138.271L358.808,138.101L358.162,136.234L356.982,136.776L356.186,138.02L352.795,137.592L351.525,138.578L349.379,137.279L346.621,137.187L346.356,136.212L344.315,134.07L341.008,134.468L339.277,118.629L337.632,105.122L348.819,103.278L352.257,104.387L353.477,105.164L354.177,104.185L356.251,105.721L357.471,106.114L361.127,104.068L363.449,104.036L365.582,101.564L368.841,98.97L372.675,96.898Z M207.545,163.396L256.452,164.092L256.535,169.779L258.419,182.372L258.239,202.411L254.642,201.214L253.708,199.915L251.3,198.815L250.696,199.818L248.298,199.77L247.785,199.149L245.595,200.278L244.675,199.655L242.685,200.215L240.841,201.957L240.129,200.956L238.193,200.135L236.152,200.119L235.501,198.803L233.125,201.334L232.378,199.89L231.3,200.311L230.497,199.364L228.32,198.454L226.661,199.92L225.978,198.347L224.658,198.132L223.923,196.868L222.155,196.326L220.959,197.356L220.221,196.401L218.387,196.474L216.386,195.415L214.503,195.478L213.918,193.273L210.986,193.043L209.857,193.374L207.871,191.109L207.158,191.204L208.089,169.112L191.794,168.27L181.121,167.55L181.54,161.877L190.085,162.527Z M33.575,29.478L34.31,29.566L35.59,31.163L35.914,32.673L35.341,36.049L38.915,38.36L42.736,37.569L44.87,37.967L47.127,39.226L47.183,40.011L51.78,39.578L52.641,40.382L54.979,40.675L57.141,40.084L60.73,39.979L63.844,40.504L65.068,40.042L80.901,43.83L81.536,45.935L83.177,47.121L83.48,48.771L81.151,51.682L80.254,53.515L79.063,54.647L78.962,55.704L77.785,57.156L76.89,57.337L74.412,60.912L74.629,62.553L76.097,63.075L76.541,64.129L74.686,67.534L70.123,87.822L56.528,84.766L45.996,82.061L37.648,79.859L26.913,76.688L20.052,74.765L12.203,72.473L11.453,70.874L11.926,67.172L12.809,64.771L12.318,62.533L13.645,60.988L15.101,58.246L17.206,55.508L18.554,52.93L22.206,43.979L22.459,42.695L24.438,38.89L26.416,33.407L26.936,30.204L27.863,28.482L31.345,27.787L32.292,29.422Z M378.364,92.779L378.851,95.604L414.886,88.524L416.116,89.849L416.728,89.649L417.997,90.707L418.186,92.391L420.091,93.945L421.362,93.721L421.902,94.43L421.002,95.466L420.827,96.786L419.269,99.566L420.207,100.572L419.372,102.806L419.748,104.066L420.876,104.198L421.281,105.579L424.163,107.298L423.972,108.366L421.818,110.772L421.87,111.656L419.82,113.07L418.05,113.138L416.887,114.669L385.683,120.776L376.846,122.278L375.155,111.984L372.675,96.898L372.675,96.898L374.106,96.029L378.332,92.594Z M449.563,83.859L450.717,85.681L449.18,86.35ZM445.873,80.837L447.065,80.522L448.195,82.997L449.245,83.556L448.222,83.635L447.81,85.486L448.148,87.849L445.212,89.198L445.436,88.044L443.739,81.525Z M365.098,177.797L366.889,176.472L369.309,175.682L380.553,174.541L380.789,175.705L381.623,174.877L383.32,176.565L383.38,177.883L393.602,176.362L405.856,185.192L404.351,186.04L402.676,188.248L401.229,191.487L401.292,193.881L400.046,196.001L397.963,196.367L397.74,197.797L395.803,199.649L394.85,201.454L393.021,202.457L391.206,204.526L391.133,205.359L389.332,206.6L387.608,209.337L385.286,208.698L384.934,206.801L382.972,204.021L381.566,203.421L381.093,201.036L380.196,199.284L377.436,197.939L375.494,196.088L375.403,194.778L372.637,193.233L371.117,191.312L368.792,190.197L366.998,188.056L366.514,186.801L364.763,184.525L363.836,184.842L360.453,182.845L360.445,181.65L362.064,178.973Z M180.823,59.736L239.669,62.527L239.488,63.824L237.307,66.221L238.678,68.399L240.497,69.71L240.4,90.071L239.326,90.314L239.768,91.25L239.487,93.24L240.515,94.365L239.881,95.171L239.598,97.411L238.865,99.088L240.391,101.589L238.896,101.269L238.364,99.645L234.047,97.484L230.166,97.425L228.998,97.154L227.983,98.255L223.791,96.102L223.535,95.535L197.849,94.436L183.928,93.424L177.959,92.94L179.83,70.402L179.96,70.413Z M315.513,166.945L315.194,164.902L317.197,165.234L328.442,163.941L338.373,163.336L340.12,163.015L346.813,162.608L354.449,161.775L354.572,161.569L372.334,159.189L372.347,161.905L370.769,162.711L369.956,164.98L368.275,164.785L365.053,167.459L364.65,166.38L363.526,167.295L361.909,170.106L359.675,171.041L357.683,173.072L355.216,173.458L353.2,175.416L352.74,177.363L350.98,177.834L351.06,180.53L339.329,181.961L323.299,183.383L315.59,184.114L313.134,184.313L296.279,185.487L297.163,185.12L297.943,183.071L297.623,180.337L299.2,178.36L299.387,176.596L300.687,175.885L300.845,173.746L302.432,170.759L302.192,167.956L302.684,167.923L303.274,167.882L304.347,167.681Z M191.794,168.27L208.089,169.112L207.158,191.204L207.871,191.109L209.857,193.374L210.986,193.043L213.918,193.273L214.503,195.478L216.386,195.415L218.387,196.474L220.221,196.401L220.959,197.356L222.155,196.326L223.923,196.868L224.658,198.132L225.978,198.347L226.661,199.92L228.32,198.454L230.497,199.364L231.3,200.311L232.378,199.89L233.125,201.334L235.501,198.803L236.152,200.119L238.193,200.135L240.129,200.956L240.841,201.957L242.685,200.215L244.675,199.655L245.595,200.278L247.785,199.149L248.298,199.77L250.696,199.818L251.3,198.815L253.708,199.915L254.642,201.214L258.239,202.411L259.229,203.455L261.062,202.86L262.404,203.333L262.528,209.377L262.768,221.016L264.915,223.458L265.024,225.943L267.77,230.475L267.991,232.892L267.058,235.835L266.13,237.037L266.487,238.58L265.822,239.774L266.622,241.926L264.411,246.006L265.295,247.102L263.684,247.199L258.602,248.84L256.759,248L256.412,246.146L255.139,247.463L254.221,247.164L253.754,248.779L254.785,249.449L254.971,251.551L253.156,253.798L250.197,256.605L244.225,259.591L243.626,259.097L241.828,259.835L241.775,259.156L239.323,259.641L238.188,258.213L237.479,258.517L240.078,261.436L238.162,262.352L236.364,261.783L236.074,263.818L233.808,265.895L231.454,269.752L229.908,273.797L228.807,273.472L228.507,274.945L229.675,274.594L229.075,277.539L228.296,277.65L228.213,279.309L229.144,280.247L229.371,283.631L230.472,284.814L230.722,286.966L231.595,288.88L228.431,290L227.164,288.506L224.761,287.909L221.561,287.963L218.86,286.055L216.793,285.815L215.27,284.297L213.162,283.741L211.757,282.281L210.925,278.87L209.164,276.777L209.448,275.063L208.686,273.187L209.021,271.596L207.82,269.763L206.781,269.538L205.141,267.869L204.675,265.813L203.275,263.901L201.211,262.266L200.33,258.825L199.394,257.854L198.222,255.075L197.898,252.83L196.735,251.161L194.705,249.631L194.273,248.617L192.383,247.646L191.018,245.088L186.763,244.277L184.183,244.239L182.044,243.229L181.483,244.371L179.1,244.58L177.163,246.802L175.814,250.49L175.216,250.508L173.701,252.628L172.074,252.567L169.779,250.651L163.922,247.373L162.868,245.79L160.635,244.163L159.268,240.805L159.427,237.894L157.996,235.395L157.816,233.322L156.881,231.925L153.317,229.645L151.577,226.85L150.042,225.756L148.543,223.351L146.261,221.921L144.913,218.773L143.575,217.999L142.627,216.579L143.012,215.496L176.722,218.641L177.568,207.28L178.628,195.794L180.777,167.524L181.121,167.55Z M109.899,95.769L119.103,97.231L117.303,108.505L134.032,110.996L131.061,132.153L129.687,141.744L129.659,143.003L127.837,156.193L114.958,154.251L83.683,148.906L94.482,92.964Z M437.423,47.886L437.792,48.943L437.206,51.021L438.401,52.632L437.611,54.828L435.27,56.526L436.011,59.221L435.556,60.3L435.499,62.945L434.669,65.467L435.238,70.264L435.722,71.811L435.176,72.591L435.499,74.244L436.413,74.912L429.876,76.343L429.552,75.397L427.956,67.715L426.257,66.208L426.47,65.196L425.047,62.344L425.409,59.891L425.11,57.728L423.899,55.914L423.828,53.761L423.186,51.492L431.234,49.618Z M424.354,133.001L425.634,132.526L424.92,134.669L423.885,135.6L423.734,138.321L422.826,142.868L421.529,144.041L420.725,142.615L420.755,139.029L422.141,134.189ZM419.163,134.83L418.927,134.88L419.352,134.79ZM395.787,121.924L400.93,124.825L401.446,122.496L402.786,122.425L403.899,123.162L404.226,124.817L406.11,125.016L407.441,125.833L408.417,127.294L408.025,129.222L407.09,129.92L406.75,131.645L407.359,132.735L409.594,131.903L410.379,133.594L413.551,133.71L414.693,134.941L417.427,135.965L416.992,139.315L418.506,141.552L417.558,142.965L417.709,144.402L418.989,145.028L418.074,146.625L415.884,145.229L415.579,145.93L417.428,146.888L421.808,146.283L423.804,150.202L391.674,156.597L384.718,157.723L378.611,158.529L372.334,159.189L354.572,161.569L359.135,159.322L359.549,158.003L361.19,157.336L361.15,156.331L362.355,155.405L362.233,154.539L365.234,152.406L368.163,148.88L368.157,149.829L369.647,151.566L371.283,152.268L372.384,152.037L373.843,150.294L375.231,151.279L377.399,150.304L381.04,147.497L381.488,148.121L382.861,146.879L382.569,144.836L383.226,142.888L384.566,140.95L384.886,138.803L386.233,136.414L386.447,133.712L388.18,134.996L389.686,135.238L390.42,134.155L391.539,129.826L392.799,130.614L395.943,125.329Z M87.43,10.69L84.307,24.187L81.048,38.861L80.719,39.736L81.291,41.89L80.901,43.83L65.068,40.042L63.844,40.504L60.73,39.979L57.141,40.084L54.979,40.675L52.641,40.382L51.78,39.578L47.183,40.011L47.127,39.226L44.87,37.967L42.736,37.569L38.915,38.36L35.341,36.049L35.914,32.673L35.59,31.163L34.31,29.566L33.575,29.478L32.292,29.422L31.345,27.787L30.113,26.967L28.808,27.283L27.659,25.902L28.394,24.512L29.609,24.042L28.779,21.34L29.547,15.196L29.176,14.246L29.638,9.883L28.688,7.853L29.075,4.502L30.525,2.64L31.686,4.09L34.259,6.352L36.311,6.907L38.19,8.044L40.265,8.081L40.909,9.293L42.648,9.735L43.171,12.443L44.107,12.52L43.355,15.756L43.053,18.805L43.868,18.718L43.847,16.025L44.74,13.659L46.652,11.52L45.974,10.243L46.398,8.45L46.234,6.3L46.996,4.988L46.99,3.204L45.907,2.638L45.194,1.095L45.811,0ZM43.947,7.492L45.041,7.297L44.255,9.432L43.332,8.272ZM42.571,3.902L43.777,2.722L44.354,4.739L43.61,6.118L42.057,5.219Z M375.155,111.984L376.846,122.278L385.683,120.776L386.606,126.578L388.104,125.234L389.443,123.027L390.628,123.194L392.002,121.229L394.66,121.563L394.756,120.274L396.161,120.074L396.787,119.065L398.424,119.834L399.862,119.557L401.446,122.496L400.93,124.825L395.787,121.924L395.943,125.329L392.799,130.614L391.539,129.826L390.42,134.155L389.686,135.238L388.18,134.996L386.447,133.712L386.233,136.414L384.886,138.803L384.566,140.95L383.226,142.888L382.569,144.836L382.861,146.879L381.488,148.121L381.04,147.497L377.399,150.304L375.231,151.279L373.843,150.294L372.384,152.037L371.283,152.268L369.647,151.566L368.157,149.829L368.163,148.88L366.982,148.866L365.11,147.813L364.489,146.576L362.86,145.105L361.499,143.089L361.255,139.65L363.501,139.076L363.618,137.543L364.571,136.772L363.892,135.102L365.097,132.145L366.628,133.687L367.366,132.502L366.935,131.239L367.69,128.913L368.685,128.824L369.422,127.067L370.474,127.663L371.474,127.001L374.212,122.833L374.334,118.573L375.049,115.672L374.813,113.938L374.014,112.803Z M287.593,54.095L289.083,54.686L290.054,56.544L298.257,58.282L301.729,59.695L302.731,59.245L306.17,60.083L307.192,61.425L308.945,62.641L309.03,64.621L308.427,66.227L310.311,66.312L309.715,67.981L311.034,69.047L310.85,70.432L309.346,70.813L308.258,73.52L307.927,75.353L308.874,75.585L310.001,74.304L311.117,71.965L312.698,70.952L313.743,67.993L315.334,67.222L315.344,68.715L314.34,70.178L312.541,75.077L312.159,77.728L312.369,79.58L311.586,80.273L311.057,82.877L311.512,85.021L310.925,86.508L310.247,90.125L310.71,92.897L311.912,95.293L311.881,98.609L303.744,99.27L288.436,100.097L287.768,98.699L284.762,97.551L284.038,95.78L283.638,93.245L284.555,91.827L283.289,90.77L283.115,89.097L282.603,87.815L282.736,86.004L281.06,83.597L279.754,83.223L277.367,81.403L276.905,79.931L274.386,78.792L273.501,77.584L271.823,77.461L269.644,75.488L270.071,71.317L269.831,69.404L270.684,67.639L269.637,66.251L268.723,66.157L268.8,64.545L270.53,62.072L272.735,61.063L273.182,60.365L272.922,53.701L274.46,52.714L275.076,53.182L276.827,53.23L282.134,51.061L284.072,49.909L284.795,50.672L283.814,52.208L286.474,53.975Z M139.999,65.891L165.081,68.941L179.83,70.402L177.959,92.94L176.033,115.615L161.911,114.352L143.503,112.184L134.032,110.996L117.303,108.505L119.103,97.231L123.537,69.458L124.433,63.571Z";