npm install @tanstack/charts
@@ -1309,6 +1312,7 @@ function collectionCases(collectionId: string): ConformanceCaseMeta[] {
'radar',
'radial',
'tooltip',
+ 'geography',
]
return conformanceCases
.filter((entry) => entry.collections?.includes(collectionId))
diff --git a/llms.txt b/llms.txt
index 4fca1560..f2eb4eba 100644
--- a/llms.txt
+++ b/llms.txt
@@ -108,6 +108,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam
- docs/reference/marks/rules-links-arrows-vectors-and-ticks.md: Rules, Links, Arrows, Vectors, and Ticks: Reference for ruleX, ruleY, link, arrow, vector, tickX, and tickY geometry, channels, defaults, curves, and focus anchors.
- docs/reference/marks/text-frame-and-facet.md: Text, Frame, and Facet Marks: Reference for data labels, chart frames, responsive small multiples, shared or per-cell axes, labels, and facet constraints.
- docs/reference/marks/geo.md: Geo Shape Mark: Reference for projecting GeoJSON with an opt-in D3-backed geoShape mark.
+- docs/reference/marks/city.md: City Map Renderer: Render metric-driven buildings with categorical districts, selection, camera controls, lighting, and weather in React.
- docs/reference/marks/polar.md: Polar Marks: Reference for the opt-in polar container, radial marks, guides, injected scale factories, and D3-backed geometry.
Authoring rules:
diff --git a/package.json b/package.json
index 8d00f8a3..a48dd570 100644
--- a/package.json
+++ b/package.json
@@ -145,6 +145,7 @@
"vite": "^8.0.16",
"vite-plugin-solid": "^2.11.12",
"vitest": "^4.1.9",
- "world-atlas": "2.0.2"
+ "world-atlas": "2.0.2",
+ "three": "0.180.0"
}
}
diff --git a/packages/charts-core/docs/config.json b/packages/charts-core/docs/config.json
index 87ed2e2d..43a735a9 100644
--- a/packages/charts-core/docs/config.json
+++ b/packages/charts-core/docs/config.json
@@ -1071,6 +1071,10 @@
{
"label": "shadcn Tooltip - No Label",
"to": "/charts/catalog/charts/197-shadcn-tooltip-label-none/"
+ },
+ {
+ "label": "3D city map",
+ "to": "/charts/catalog/charts/city-map/"
}
]
},
@@ -1311,6 +1315,10 @@
"label": "Geo Shape Mark",
"to": "reference/marks/geo"
},
+ {
+ "label": "City Map Renderer",
+ "to": "reference/marks/city"
+ },
{
"label": "Polar Marks",
"to": "reference/marks/polar"
diff --git a/packages/charts-core/docs/examples/maps-and-spatial.md b/packages/charts-core/docs/examples/maps-and-spatial.md
index ede6a4a9..66407e51 100644
--- a/packages/charts-core/docs/examples/maps-and-spatial.md
+++ b/packages/charts-core/docs/examples/maps-and-spatial.md
@@ -272,3 +272,12 @@ the controlled gesture loop.
- Verify labels and legends at narrow widths.
- Provide a non-spatial exact-value path through
[Accessibility](../guides/accessibility.md).
+
+## Category districts and metric-driven buildings
+
+The city renderer encodes records as buildings: height follows a nonnegative
+metric and districts represent category intersections. The example includes
+camera controls, selection, lighting, and weather. See the
+[City Map Renderer reference](../reference/marks/city.md).
+
+
diff --git a/packages/charts-core/docs/framework/react/adapter.md b/packages/charts-core/docs/framework/react/adapter.md
index 8a128a05..16d018d5 100644
--- a/packages/charts-core/docs/framework/react/adapter.md
+++ b/packages/charts-core/docs/framework/react/adapter.md
@@ -275,3 +275,7 @@ The adapter does not redefine:
Use [Scales](../../concepts/scales-and-d3.md) for the injected primitive
boundary and the [core reference](../../reference/index.md) for those APIs.
+
+## 3D city map
+
+See the [optional city map](../../reference/marks/city.md) for metric-driven buildings, categorical districts, and React controls.
diff --git a/packages/charts-core/docs/reference/marks/city.md b/packages/charts-core/docs/reference/marks/city.md
new file mode 100644
index 00000000..896d6518
--- /dev/null
+++ b/packages/charts-core/docs/reference/marks/city.md
@@ -0,0 +1,153 @@
+---
+title: City Map Renderer
+description: Render metric-driven buildings with categorical districts, selection, camera controls, lighting, and weather in React.
+---
+
+The city map represents records as buildings. Building height encodes a nonnegative
+metric, districts encode intersections of up to three categories, and health status
+controls the building effects. It supports orbit, pan, zoom, selection, search,
+district labels, day/night lighting, rain, snow, and reduced motion.
+
+The city module is optional. Ordinary chart imports do not load Three.js or city
+styles. Install `three@^0.180.0` alongside React and TanStack Charts, then import
+the city entry in your client component:
+
+```tsx
+'use client'
+
+import { CityMap, type CityDatum } from '@tanstack/charts/react/city'
+
+const records: CityDatum[] = [
+ {
+ id: 'store-1',
+ name: 'North store',
+ metric: { label: 'Orders', value: 1200 },
+ categories: [{ label: 'Retail', value: 'retail' }],
+ status: 'healthy',
+ },
+ {
+ id: 'store-2',
+ name: 'South store',
+ metric: { label: 'Orders', value: 700 },
+ categories: [{ label: 'Retail', value: 'retail' }],
+ status: 'critical',
+ },
+]
+
+export default function Stores() {
+ return (
+
+ )
+}
+```
+
+The compatibility entry is `@tanstack/react-charts/city`. The component owns its
+scoped styles, following the renderer pattern used by the other chart examples.
+
+Each row needs a unique string or numeric `id`, a `name`, a metric with a nonempty
+label and a finite nonnegative value, and a `status` of `healthy`, `warning`, or
+`critical`. Supply one to three `{ value, label }` categories per row, with consistent
+labels across rows. There may be at most three distinct categories in the dataset.
+Alternatively, supply a district `mask` from 1 to 7. Empty data is supported.
+Optional `updatedAt` and `statusChangedAt` fields accept ISO 8601 timestamps with
+a timezone. Rows are never mutated; selection returns the original datum.
+
+## Building height
+
+Buildings use a continuous square-root scale adapted to the dataset's metric
+values. The smallest value maps to a one-floor house and the largest to a
+14-floor building (0.95–16.8 scene units). Intermediate heights use
+`sqrt((value - min) / (max - min))`, clamped to 0–1, to interpolate between those
+heights. This gives smaller and intermediate values more presence than a linear
+scale when large values dominate. Height is not proportional to the metric:
+twice the height does not mean twice the value.
+
+Equal values always produce equal heights. If all values are equal, including a
+single record or all zeros, buildings use the midpoint height (8.875 scene units).
+The scale is shared across districts and recalculated when the data changes.
+Search and selection preserve the scale. Separate datasets have independent
+scales, so heights should be compared within the same dataset.
+
+When supplying a precomputed `CityLayout`, set its optional `metricDomain` to the
+full dataset's `[min, max]` to keep heights stable across application-owned
+filters or pagination. Otherwise, the domain is derived from the layout's rows.
+Changes to this domain rebuild heights even if the visible metric values have
+not changed. Exact metric values remain available in tooltips and details.
+
+## Composition
+
+`CityMap` includes the controls, search, details, and critical-record monitor.
+`CityChart` accepts a memoized `defineCityChart(data)` definition and renders the
+surface, tooltip, and optional district labels. Its selection callback uses the
+usual TanStack `ChartPoint`; `CityMap.onSelect` receives the datum instead.
+`cityMark`, `cityFocus`, and `createCityRenderer` are also exported for composition
+with the existing custom renderer host. Use one city mark per renderer surface.
+Definitions can be shared across mounted charts; each mount owns its camera,
+WebGL context, focus resolver, and resource lifetime.
+
+## Controls and appearance
+
+Use `view="iso"` or `view="top"`, `weather="none" | "rain" | "snow"`, and
+`lightingMode="auto" | "day" | "twilight" | "night"`. Automatic lighting follows
+the local clock. Weather is supplied by the application; the package makes no
+weather or data requests.
+
+`life`, `effects`, `autoRotate`, and `showDistrictLabels` have optional change
+callbacks for controlled use. `showControls`, `showSearch`, `showDetails`,
+`showMonitor`, `showCompass`, and `showInstructions` control the included UI.
+`showEnvironmentControls` enables the lighting and weather selectors.
+`busy` disables interactions while the application updates its data.
+
+Pass a `CityChartHandle` ref for `zoom`, `view`, `select`, `deselect`, `cancel`,
+`capture`, `play`, `scrollIntoView`, and `stats`. Selection accepts an options bag:
+`ref.current?.select(record, { focus: true, maxZoom: true })`.
+
+The renderer supplies scoped theme variables. Override `--atlas-city-*` properties
+in your own CSS, or set `theme={null}` to provide all variables yourself. The map
+needs a measurable container width and reserves at least 520 pixels of height.
+
+## Rendering and accessibility
+
+Use a client boundary for React Server Components. Server rendering outputs the
+standard semantic SVG top view; WebGL paints above it after mounting in a browser.
+The SVG remains available when WebGL cannot initialize.
+WebGL initialization or context loss produces a visible error message. The map
+honors reduced motion, exposes keyboard focus/selection through the chart host,
+and supports `+`/`-` for zoom, `0` for the isometric view, and Escape to clear
+selection. Controls and descriptions follow the browser language (`en-US` and
+`pt-BR`; other languages use English).
+
+Dispose charts by unmounting them. Unmounting releases observers, listeners,
+animations, geometry, materials, textures, and the WebGL renderer.
+
+## Component and engine contracts
+
+| Type | Contract |
+| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
+| `CityMapProps
` | Full-map data, controlled settings, visibility flags, datum selection, and ref. |
+| `CityChartProps` | Definition, selected datum, renderer settings, callbacks, and render-prop children. |
+| `CityChartState` | Render-prop state containing readiness, localized error, environment controls, and weather override. |
+| `CityDefinitionOptions` | Definition options plus an optional precomputed city layout. |
+| `CityLayout` | Positioned copies of the input rows, district bounds and anchors, road bounds, and city center. |
+| `CityEngine` | Mounted renderer service passed to lifecycle callbacks; owns activity, view, zoom, focus, selection, data, and disposal. |
+| `CityRendererOptions` | Initial view/activity settings and mount, data, destroy, error, hover, projection, and frame callbacks. |
+| `CityStats` | Draw calls, triangles, point count, lighting, and weather diagnostics returned by the handle. |
+| `CityView` | Isometric (`iso`) or overhead (`top`) camera view. |
+| `CityWeather` | Application-supplied `none`, `rain`, or `snow` effect. |
+| `CityLighting` | Local-clock `auto` mode, or fixed `day`, `twilight`, or `night`. |
+| `EnvironmentControlsProps` | Controlled lighting/weather values, change callbacks, and an optional disabled state. |
+
+`EnvironmentControls` renders the lighting and weather selectors independently of
+the full map. Its callbacks receive the next mode. `CityRendererOptions.onData`
+receives the mounted engine and `{ resetView }`; `hover` receives the datum and an
+optional `{ event }` context. Other lifecycle callbacks receive one engine or error.
+
+## Catalog example
+
+See the [3D city map](https://tanstack.com/charts/catalog/charts/city-map/) for the live example
+and its complete source. Run `pnpm dev:conformance` to use the catalog locally.
+The compact preview shows the same definition as a semantic top view.
+
+The shared conformance runner checks the SVG district layout against an isolated
+Plot reference. The live catalog mounts the full WebGL map. Both paths use the
+same source data and standard case metadata.
diff --git a/packages/charts-core/llms.txt b/packages/charts-core/llms.txt
index 4fca1560..f2eb4eba 100644
--- a/packages/charts-core/llms.txt
+++ b/packages/charts-core/llms.txt
@@ -108,6 +108,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam
- docs/reference/marks/rules-links-arrows-vectors-and-ticks.md: Rules, Links, Arrows, Vectors, and Ticks: Reference for ruleX, ruleY, link, arrow, vector, tickX, and tickY geometry, channels, defaults, curves, and focus anchors.
- docs/reference/marks/text-frame-and-facet.md: Text, Frame, and Facet Marks: Reference for data labels, chart frames, responsive small multiples, shared or per-cell axes, labels, and facet constraints.
- docs/reference/marks/geo.md: Geo Shape Mark: Reference for projecting GeoJSON with an opt-in D3-backed geoShape mark.
+- docs/reference/marks/city.md: City Map Renderer: Render metric-driven buildings with categorical districts, selection, camera controls, lighting, and weather in React.
- docs/reference/marks/polar.md: Polar Marks: Reference for the opt-in polar container, radial marks, guides, injected scale factories, and D3-backed geometry.
Authoring rules:
diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json
index 74dcfef5..61d50640 100644
--- a/packages/charts-core/package.json
+++ b/packages/charts-core/package.json
@@ -55,7 +55,8 @@
"react-native-svg": ">=15.15.4 <16",
"solid-js": ">=1.8",
"svelte": "^5.20.0",
- "vue": ">=3.5"
+ "vue": ">=3.5",
+ "three": "^0.180.0"
},
"peerDependenciesMeta": {
"@angular/core": {
@@ -96,6 +97,9 @@
},
"vue": {
"optional": true
+ },
+ "three": {
+ "optional": true
}
},
"devDependencies": {
@@ -225,7 +229,8 @@
"./violin": "./src/violin.ts",
"./view": "./src/view.ts",
"./vue": "./src/vue.ts",
- "./waffle": "./src/waffle.ts"
+ "./waffle": "./src/waffle.ts",
+ "./react/city": "./src/react-city.ts"
},
"publishConfig": {
"access": "public",
@@ -706,6 +711,10 @@
"./waffle": {
"types": "./dist/waffle.d.ts",
"import": "./dist/waffle.js"
+ },
+ "./react/city": {
+ "types": "./dist/react/city.d.ts",
+ "import": "./dist/react/city.js"
}
}
}
diff --git a/packages/charts-core/src/react-city.ts b/packages/charts-core/src/react-city.ts
new file mode 100644
index 00000000..0d113bd3
--- /dev/null
+++ b/packages/charts-core/src/react-city.ts
@@ -0,0 +1,3 @@
+'use client'
+
+export * from '../../react-charts/src/city'
diff --git a/packages/react-charts/package.json b/packages/react-charts/package.json
index 15c1b24c..4403dbe1 100644
--- a/packages/react-charts/package.json
+++ b/packages/react-charts/package.json
@@ -19,7 +19,8 @@
".": "./src/index.ts",
"./canvas": "./src/canvas.ts",
"./core": "./src/core.ts",
- "./tooltip": "./src/tooltip.tsx"
+ "./tooltip": "./src/tooltip.tsx",
+ "./city": "./src/city.ts"
},
"dependencies": {
"@tanstack/charts": "workspace:*"
@@ -30,7 +31,8 @@
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
- "react-dom": "^18.0.0 || ^19.0.0"
+ "react-dom": "^18.0.0 || ^19.0.0",
+ "three": "^0.180.0"
},
"publishConfig": {
"access": "public",
@@ -51,7 +53,16 @@
"./tooltip": {
"types": "./dist/tooltip.d.ts",
"import": "./dist/tooltip.js"
+ },
+ "./city": {
+ "types": "./dist/city.d.ts",
+ "import": "./dist/city.js"
}
}
+ },
+ "peerDependenciesMeta": {
+ "three": {
+ "optional": true
+ }
}
}
diff --git a/packages/react-charts/src/city.test.tsx b/packages/react-charts/src/city.test.tsx
new file mode 100644
index 00000000..af527219
--- /dev/null
+++ b/packages/react-charts/src/city.test.tsx
@@ -0,0 +1,54 @@
+// @vitest-environment node
+import * as React from 'react'
+import { fileURLToPath } from 'node:url'
+import { renderToString } from 'react-dom/server'
+import { build } from 'esbuild'
+import { describe, expect, it } from 'vitest'
+import { CityMap, CityChart, defineCityChart, type CityDatum } from './city'
+
+const rows: CityDatum[] = [
+ {
+ id: 7,
+ name: 'Store',
+ metric: { label: 'Orders', value: 42 },
+ categories: [{ label: 'Retail', value: 'retail' }],
+ status: 'critical',
+ },
+]
+
+describe('React city integration', () => {
+ it('server renders the complete map and composable chart without WebGL', () => {
+ const map = renderToString( )
+ expect(map).toContain('Store health')
+ expect(map).toContain('data-city-surface')
+ expect(map).toContain('data-city-fallback')
+ expect(map).toContain('ts-chart__rect')
+ expect(map).toContain('Camera controls')
+ expect(map).not.toContain(' ,
+ )
+ expect(chart).toContain('data-city-surface')
+ expect(chart).not.toContain(' {
+ const result = await build({
+ stdin: {
+ contents: "export { Chart } from '@tanstack/react-charts'",
+ resolveDir: fileURLToPath(new URL('.', import.meta.url)),
+ loader: 'ts',
+ },
+ bundle: true,
+ format: 'esm',
+ write: false,
+ metafile: true,
+ external: ['react', 'react-dom'],
+ })
+ expect(
+ Object.keys(result.metafile!.inputs).some(
+ (path) => path.includes('/city') || path.includes('/three/'),
+ ),
+ ).toBe(false)
+ })
+})
diff --git a/packages/react-charts/src/city.ts b/packages/react-charts/src/city.ts
new file mode 100644
index 00000000..06336f48
--- /dev/null
+++ b/packages/react-charts/src/city.ts
@@ -0,0 +1,34 @@
+'use client'
+
+import * as runtime from './city/index.js'
+import { CityMap as CityMapImplementation } from './city/map.jsx'
+import { CityChart as CityChartImplementation } from './city/react.jsx'
+import { EnvironmentControls as EnvironmentControlsImplementation } from './city/environment-controls.jsx'
+import type * as Contract from './city/types'
+
+export const defineCityChart: typeof Contract.defineCityChart =
+ runtime.defineCityChart
+export const cityMark: typeof Contract.cityMark = runtime.cityMark
+export const cityFocus: typeof Contract.cityFocus = runtime.cityFocus
+export const createCityRenderer: typeof Contract.createCityRenderer =
+ runtime.createCityRenderer
+export const CityMap: typeof Contract.CityMap = CityMapImplementation
+export const CityChart: typeof Contract.CityChart = CityChartImplementation
+export const EnvironmentControls: typeof Contract.EnvironmentControls =
+ EnvironmentControlsImplementation
+export type {
+ CityDatum,
+ CityLayout,
+ CityDefinitionOptions,
+ CityEngine,
+ CityRendererOptions,
+ CityView,
+ CityWeather,
+ CityLighting,
+ CityStats,
+ CityChartHandle,
+ CityMapProps,
+ CityChartProps,
+ CityChartState,
+ EnvironmentControlsProps,
+} from './city/types'
diff --git a/packages/react-charts/src/city/camera.test.mjs b/packages/react-charts/src/city/camera.test.mjs
new file mode 100644
index 00000000..0dc57881
--- /dev/null
+++ b/packages/react-charts/src/city/camera.test.mjs
@@ -0,0 +1,133 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
+import { City } from './engine/city.js'
+
+test('isometric view keeps a fixed tilt when rotating, recentering and returning from the top view', () => {
+ const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 800)
+ camera.position.set(160, 160, 160)
+ const controls = new OrbitControls(camera, null),
+ city = { camera, controls, layout: { center: [14, -8] } }
+ const angle = Math.acos(1 / Math.sqrt(3))
+ for (const previousMode of ['iso', 'top']) {
+ City.prototype.view.call(city, previousMode, true)
+ if (previousMode === 'top') assert.ok(controls.getPolarAngle() < 0.00001)
+ City.prototype.view.call(city, 'iso', true)
+ assert.deepEqual(controls.target.toArray(), [14, 0, -8])
+ let azimuth
+ for (const offset of [
+ [150, 5, 10],
+ [-150, 300, 10],
+ [10, 500, -150],
+ ]) {
+ camera.position.copy(controls.target).add(new THREE.Vector3(...offset))
+ controls.update()
+ assert.ok(Math.abs(controls.getPolarAngle() - angle) < 1e-10)
+ if (azimuth !== undefined)
+ assert.notEqual(controls.getAzimuthalAngle(), azimuth)
+ azimuth = controls.getAzimuthalAngle()
+ }
+ camera.zoom = 2
+ camera.updateProjectionMatrix()
+ controls.target.x += 5
+ controls.update()
+ assert.equal(camera.zoom, 2)
+ assert.ok(Math.abs(controls.getPolarAngle() - angle) < 1e-10)
+ }
+})
+
+test('a frame older than a reduced-motion transition cannot overshoot the camera target', () => {
+ const previousDocument = globalThis.document
+ globalThis.document = { hidden: false }
+ try {
+ const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 800)
+ camera.position.set(160, 160, 160)
+ const controls = new OrbitControls(camera, null)
+ const city = {
+ camera,
+ controls,
+ layout: { center: [14, -8] },
+ running: true,
+ reduceMotion: true,
+ elapsed: 0,
+ callbacks: {},
+ renderer: { render() {} },
+ invalidate() {},
+ updateEffects() {},
+ }
+ City.prototype.view.call(city, 'iso', true)
+ City.prototype.view.call(city, 'top')
+ const start = city.fly.start
+ City.prototype.animate.call(city, start - 1)
+ assert.ok(city.fly, 'an earlier RAF must not complete the transition')
+ City.prototype.animate.call(city, start + 2)
+ assert.equal(city.fly, null)
+ assert.ok(Math.abs(controls.getAzimuthalAngle()) < 1e-9)
+ assert.ok(controls.getPolarAngle() < 0.00001)
+ assert.deepEqual(controls.target.toArray(), [14, 0, -8])
+ } finally {
+ if (previousDocument === undefined) delete globalThis.document
+ else globalThis.document = previousDocument
+ }
+})
+
+test('auto-rotation is opt-in, slow, frame-rate independent and pauses when controls are locked', () => {
+ const previousDocument = globalThis.document
+ globalThis.document = { hidden: false }
+ try {
+ const results = []
+ for (const fps of [5, 30, 60, 120]) {
+ const camera = new THREE.OrthographicCamera(
+ -100,
+ 100,
+ 100,
+ -100,
+ 0.1,
+ 800,
+ )
+ camera.position.set(160, 160, 160)
+ const controls = new OrbitControls(camera, null)
+ controls.autoRotateSpeed = -0.3
+ controls.update()
+ const city = {
+ camera,
+ controls,
+ running: true,
+ life: false,
+ effects: false,
+ reduceMotion: true,
+ elapsed: 0,
+ callbacks: {},
+ renderer: { render() {} },
+ invalidate() {},
+ updateEffects() {},
+ }
+ const angle = controls.getAzimuthalAngle()
+ City.prototype.animate.call(city, 1000)
+ City.prototype.animate.call(city, 2000)
+ assert.equal(controls.getAzimuthalAngle(), angle)
+ City.prototype.setAutoRotate.call(city, true)
+ City.prototype.animate.call(city, 3000)
+ for (let frame = 1; frame <= fps * 10; frame++) {
+ City.prototype.animate.call(city, 3000 + (frame * 1000) / fps)
+ }
+ const delta = controls.getAzimuthalAngle() - angle
+ assert.ok(Math.abs(delta - Math.PI / 10) < 1e-9)
+ results.push(delta)
+ controls.enabled = false
+ const paused = controls.getAzimuthalAngle()
+ City.prototype.animate.call(city, 13100)
+ assert.ok(Math.abs(controls.getAzimuthalAngle() - paused) < 1e-9)
+ controls.enabled = true
+ City.prototype.setAutoRotate.call(city, false)
+ City.prototype.animate.call(city, 14000)
+ City.prototype.animate.call(city, 14050)
+ assert.ok(Math.abs(controls.getAzimuthalAngle() - paused) < 1e-9)
+ }
+ assert.ok(Math.max(...results) - Math.min(...results) < 1e-9)
+ } finally {
+ if (previousDocument === undefined) delete globalThis.document
+ else globalThis.document = previousDocument
+ }
+})
diff --git a/packages/react-charts/src/city/city-chart.test.mjs b/packages/react-charts/src/city/city-chart.test.mjs
new file mode 100644
index 00000000..f0be1e37
--- /dev/null
+++ b/packages/react-charts/src/city/city-chart.test.mjs
@@ -0,0 +1,158 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import { defineChart } from '@tanstack/charts'
+import { createChartRuntime } from '@tanstack/charts/runtime'
+import { scaleLinear } from '@tanstack/charts/scales/linear'
+import {
+ cityMark,
+ defineCityChart,
+ createCityRenderer,
+ cityFocus,
+} from './index.js'
+import { bindCityPoints } from './interaction.js'
+
+import { readCityLayout } from './layout.js'
+import { layoutCity } from './engine/model.js'
+
+const data = Object.freeze([
+ Object.freeze({
+ id: 'one',
+ name: 'Empresa saudável',
+ users: 42,
+ mask: 1,
+ status: 'healthy',
+ }),
+ Object.freeze({
+ id: 'two',
+ name: 'Empresa em risco',
+ users: 2400,
+ mask: 3,
+ status: 'critical',
+ }),
+])
+const sceneFor = (definition, size = { width: 800, height: 450 }) => {
+ const runtime = createChartRuntime()
+ return runtime.render(definition, size)
+}
+
+test('cityMark composes a TanStack definition and preserves datum, indices, keys and colors', () => {
+ const definition = defineChart({
+ marks: [cityMark(data)],
+ scales: { x: { scale: scaleLinear }, y: { scale: scaleLinear } },
+ color: { domain: [1, 3], range: ['#123456', '#abcdef'] },
+ guides: false,
+ })
+ const scene = sceneFor(definition)
+ assert.equal(scene.points.length, data.length)
+ scene.points.forEach((point, i) => {
+ assert.equal(point.datum, data[i])
+ assert.equal(point.datumIndex, i)
+ assert.equal(point.key, `companies:${data[i].id}`)
+ assert.ok(Number.isFinite(point.x) && Number.isFinite(point.y))
+ })
+ assert.deepEqual(
+ scene.points.map((p) => p.color),
+ ['#123456', '#abcdef'],
+ )
+ assert.equal(data[0].x, undefined)
+ assert.equal(data[0].height, undefined)
+})
+
+test('the definition is deterministic, responsive and accepts an empty dataset', () => {
+ const definition = defineCityChart(data)
+ const first = sceneFor(definition)
+ const wide = sceneFor(definition, { width: 1200, height: 450 })
+ assert.deepEqual(
+ first.points.map((p) => p.key),
+ wide.points.map((p) => p.key),
+ )
+ assert.deepEqual(
+ first.points.map((p) => [p.xValue, p.yValue]),
+ wide.points.map((p) => [p.xValue, p.yValue]),
+ )
+ assert.deepEqual(
+ first.points.map((p) => [p.x, p.y]),
+ sceneFor(definition).points.map((p) => [p.x, p.y]),
+ )
+ assert.equal(sceneFor(defineCityChart([])).points.length, 0)
+})
+
+test('focus uses each mount surface without mixing two cities', () => {
+ const one = sceneFor(defineCityChart(data)).points
+ const two = sceneFor(defineCityChart(data)).points
+ bindCityPoints(one, () => [one[0]])
+ bindCityPoints(two, () => [two[1]])
+ assert.equal(cityFocus.resolve(one, { x: 0, y: 0 })[0], one[0])
+ assert.equal(cityFocus.resolve(two, { x: 0, y: 0 })[0], two[1])
+ assert.deepEqual(cityFocus.resolve([], { x: 0, y: 0 }), [])
+})
+
+test('the renderer generates an SSR shell without WebGL or a DOM and escapes the accessible name', () => {
+ const renderer = createCityRenderer()
+ const markup = renderer.prerender(sceneFor(defineCityChart(data)), {
+ ariaLabel: 'Saúde "A" & B',
+ })
+ assert.match(markup, /data-city-surface/)
+ assert.match(markup, /Saúde <empresas> "A" & B/)
+ assert.doesNotMatch(markup, / {
+ for (const invalid of [
+ null,
+ [{}],
+ [data[0], data[0]],
+ [{ ...data[0], users: NaN }],
+ [{ ...data[0], mask: 0 }],
+ [{ ...data[0], status: 'unknown' }],
+ ]) {
+ assert.throws(() => cityMark(invalid), /cityMark:/)
+ }
+})
+
+test('the layout follows the TanStack scene, including empty scenes, without recalculating positions', () => {
+ for (const rows of [data, []]) {
+ const layout = layoutCity(rows)
+ if (rows.length) layout.companies[0].x += 0.4
+ const scene = sceneFor(defineCityChart(rows, { layout }))
+ assert.equal(readCityLayout(scene.nodes), layout)
+ if (rows.length) assert.equal(scene.points[0].xValue, layout.companies[0].x)
+ }
+})
+
+test('the mark accepts the generic contract without a mask and retains the original datum', () => {
+ const rows = [
+ {
+ id: 'generic',
+ name: 'Aurora',
+ metric: { label: 'Receita', value: 2400.25 },
+ categories: [{ label: 'Premium', value: 'premium' }],
+ status: 'warning',
+ },
+ ]
+ const scene = sceneFor(defineCityChart(rows))
+ const layout = readCityLayout(scene.nodes)
+ assert.equal(scene.points[0].datum, rows[0])
+ assert.equal(scene.points[0].groupLabel, 'Premium')
+ assert.equal(layout.companies[0].mask, 1)
+ assert.equal(layout.companies[0].metric.value, 2400.25)
+ assert.equal(rows[0].mask, undefined)
+})
+
+test('the mark retains numeric IDs in the generic contract and validates normalized categories', () => {
+ const row = {
+ id: 101,
+ name: 'Aurora',
+ metric: { label: 'Receita', value: 42 },
+ categories: [{ label: 'Premium', value: 'premium' }],
+ status: 'healthy',
+ }
+ const scene = sceneFor(defineCityChart([row]))
+ assert.equal(readCityLayout(scene.nodes).companies[0].id, 101)
+ assert.equal(scene.points[0].datum.id, 101)
+ assert.throws(() => cityMark([{ ...row, id: undefined }]), /cityMark: id/)
+ assert.throws(
+ () => cityMark([{ ...row, mask: 1, categories: [] }]),
+ /cityMark:/,
+ )
+})
diff --git a/packages/react-charts/src/city/city-i18n.test.mjs b/packages/react-charts/src/city/city-i18n.test.mjs
new file mode 100644
index 00000000..ff966b66
--- /dev/null
+++ b/packages/react-charts/src/city/city-i18n.test.mjs
@@ -0,0 +1,55 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import {
+ mapMessages,
+ resolveMapLocale,
+ getMapI18n,
+ getBrowserMapLocale,
+} from './i18n.js'
+
+test('the map accepts both locales and uses English for unsupported languages', () => {
+ assert.equal(resolveMapLocale('pt-BR'), 'pt-BR')
+ assert.equal(resolveMapLocale('PT-br'), 'pt-BR')
+ assert.equal(resolveMapLocale('en-US'), 'en-US')
+ for (const language of [
+ 'pt',
+ 'pt-PT',
+ 'en-GB',
+ 'es-ES',
+ 'fr-FR',
+ '',
+ null,
+ undefined,
+ ]) {
+ assert.equal(resolveMapLocale(language), 'en-US')
+ }
+ assert.equal(
+ getBrowserMapLocale(),
+ 'en-US',
+ 'SSR does not depend on the host locale',
+ )
+})
+
+test('complete catalogs preserve parameters and format numbers in the locale', () => {
+ assert.deepEqual(
+ Object.keys(mapMessages['pt-BR']).sort(),
+ Object.keys(mapMessages['en-US']).sort(),
+ )
+ for (const [key, value] of Object.entries(mapMessages['pt-BR'])) {
+ assert.ok(value.trim())
+ const english = mapMessages['en-US'][key]
+ assert.ok(english.trim())
+ assert.deepEqual(
+ value.match(/\{\w+\}/g)?.sort(),
+ english.match(/\{\w+\}/g)?.sort(),
+ key,
+ )
+ }
+ const pt = getMapI18n('pt-BR')
+ const en = getMapI18n('en-US')
+ assert.equal(pt.format(2400.5), '2.400,5')
+ assert.equal(en.format(2400.5), '2,400.5')
+ assert.equal(pt.t('loadingCity', { page: 2 }), 'Carregando cidade 2…')
+ assert.equal(en.t('loadingCity', { page: 2 }), 'Loading city 2…')
+ assert.equal(getMapI18n('es-ES').t('title'), 'Business district')
+})
diff --git a/packages/react-charts/src/city/city-lighting.test.mjs b/packages/react-charts/src/city/city-lighting.test.mjs
new file mode 100644
index 00000000..7f8dc8f2
--- /dev/null
+++ b/packages/react-charts/src/city/city-lighting.test.mjs
@@ -0,0 +1,154 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import {
+ buildLighting,
+ advanceLighting,
+ LIGHTING_TRANSITION_MS,
+ lightingPeriod,
+ updateLighting,
+} from './three/lighting.js'
+import { buildBuildings } from './three/buildings.js'
+import { updateEffects } from './three/effects.js'
+import { disposeSceneGroups } from './engine/scene-resources.js'
+
+const localTime = (hour, minute = 0) => new Date(2026, 9, 18, hour, minute)
+const settleLighting = (city, date) => {
+ updateLighting(city, date, 0)
+ advanceLighting(city, LIGHTING_TRANSITION_MS)
+}
+
+test('the three levels follow local time, including period boundaries', () => {
+ for (const [hour, minute, expected] of [
+ [0, 0, 'night'],
+ [5, 59, 'night'],
+ [6, 0, 'twilight'],
+ [7, 59, 'twilight'],
+ [8, 0, 'day'],
+ [16, 59, 'day'],
+ [17, 0, 'twilight'],
+ [18, 59, 'twilight'],
+ [19, 0, 'night'],
+ [23, 59, 'night'],
+ ]) {
+ assert.equal(lightingPeriod(localTime(hour, minute)), expected)
+ assert.equal(lightingPeriod(localTime(hour, minute), false), 'day')
+ }
+})
+
+test('windows follow lighting and rebuilds while preserving independent risk effects', () => {
+ const city = {
+ scene: new THREE.Scene(),
+ buildings: new THREE.Group(),
+ life: true,
+ effects: false,
+ camera: new THREE.PerspectiveCamera(),
+ palettes: new Map([[1, new THREE.Color('#4b91ef')]]),
+ plumes: { update() {} },
+ warning: new THREE.InstancedMesh(
+ new THREE.PlaneGeometry(),
+ new THREE.MeshBasicMaterial(),
+ 3,
+ ),
+ active: new Set([1, 2, 3]),
+ }
+ city.scene.background = new THREE.Color()
+ city.scene.add(city.buildings, city.warning)
+ buildLighting(city)
+ const companies = ['healthy', 'warning', 'critical'].map((status, i) => ({
+ id: i + 1,
+ x: i * 3,
+ z: 0,
+ users: 1000,
+ mask: 1,
+ status,
+ }))
+ settleLighting(city, localTime(12))
+ buildBuildings(city, companies)
+ assert.equal(city.windows.material.emissiveIntensity, 0)
+ assert.equal(city.scene.background.getHexString(), 'e9ede4')
+ assert.equal(city.lights.hemisphere.intensity, 2.5)
+ assert.equal(city.lights.sun.intensity, 3.1)
+ assert.equal(city.lights.fill.intensity, 0.75)
+ const body = city.body
+ for (const hour of [18, 22]) {
+ settleLighting(city, localTime(hour))
+ updateEffects(city, 0)
+ assert.equal(city.body, body)
+ assert.ok(city.windows.material.emissiveIntensity > 0)
+ const color = new THREE.Color()
+ for (let i = 0; i < city.windowGlow.count; i++) {
+ city.windowGlow.getColorAt(i, color)
+ assert.equal(color.getHexString(), 'ffe0a3')
+ }
+ assert.equal(city.warning.visible, false)
+ }
+ city.effects = true
+ updateEffects(city, 0)
+ assert.equal(city.warning.visible, true)
+ const color = new THREE.Color()
+ const criticalIndex = city.windowGlow.userData.items.findIndex(
+ (item) => item.source.critical,
+ )
+ city.windowGlow.getColorAt(criticalIndex, color)
+ assert.ok(color.r > color.g && color.g > color.b)
+
+ disposeSceneGroups([city.buildings])
+ buildBuildings(city, companies)
+ assert.ok(city.windows.material.emissiveIntensity > 0)
+ city.life = false
+ settleLighting(city, localTime(22))
+ updateEffects(city, 0)
+ assert.equal(city.lightingPeriod, 'day')
+ assert.equal(city.windows.material.emissiveIntensity, 0)
+ assert.equal(city.scene.background.getHexString(), 'e9ede4')
+ assert.equal(city.warning.visible, true)
+ assert.equal(updateLighting(city, localTime(22)), false)
+ disposeSceneGroups([city.scene])
+})
+
+test('lighting and windows interpolate without jumps when the target changes during a transition', () => {
+ const city = {
+ scene: new THREE.Scene(),
+ life: true,
+ lightingMode: 'day',
+ windows: new THREE.Mesh(
+ new THREE.PlaneGeometry(),
+ new THREE.MeshStandardMaterial(),
+ ),
+ }
+ city.scene.background = new THREE.Color()
+ city.scene.add(city.windows)
+ buildLighting(city)
+ const day = city.scene.background.clone()
+ city.lightingMode = 'night'
+ updateLighting(city, localTime(12), 0)
+ assert.ok(city.scene.background.equals(day))
+ assert.equal(city.windows.material.emissiveIntensity, 0)
+ advanceLighting(city, LIGHTING_TRANSITION_MS / 2)
+ assert.equal(city.windowLight, 0.5)
+ assert.equal(city.windows.material.emissiveIntensity, 0.575)
+ assert.ok(city.lights.hemisphere.intensity > 0.65)
+ assert.ok(city.lights.hemisphere.intensity < 2.5)
+ assert.ok(!city.scene.background.equals(day))
+ const halfway = city.scene.background.clone()
+ city.lightingMode = 'twilight'
+ updateLighting(city, localTime(12), LIGHTING_TRANSITION_MS / 2)
+ assert.ok(city.scene.background.equals(halfway))
+ assert.equal(city.windowLight, 0.5)
+ advanceLighting(city, LIGHTING_TRANSITION_MS)
+ assert.equal(city.windowLight, 0.75)
+ advanceLighting(city, LIGHTING_TRANSITION_MS * 1.5)
+ assert.equal(city.scene.background.getHexString(), 'b99086')
+ assert.equal(city.windowLight, 1)
+ assert.equal(city.lightingTransition, null)
+ assert.equal(advanceLighting(city, LIGHTING_TRANSITION_MS * 2), false)
+ city.life = false
+ updateLighting(city, localTime(12), LIGHTING_TRANSITION_MS * 2)
+ advanceLighting(city, LIGHTING_TRANSITION_MS * 2.5)
+ assert.equal(city.windowLight, 0.5)
+ advanceLighting(city, LIGHTING_TRANSITION_MS * 3)
+ assert.ok(city.scene.background.equals(day))
+ assert.equal(city.windows.material.emissiveIntensity, 0)
+ disposeSceneGroups([city.scene])
+})
diff --git a/packages/react-charts/src/city/city-slider.test.mjs b/packages/react-charts/src/city/city-slider.test.mjs
new file mode 100644
index 00000000..d83f2071
--- /dev/null
+++ b/packages/react-charts/src/city/city-slider.test.mjs
@@ -0,0 +1,138 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import { CitySlider } from './engine/city-slider.js'
+
+function setup(t) {
+ const originalWindow = globalThis.window,
+ originalDocument = globalThis.document
+ const animations = []
+ class Element {
+ children = []
+ classList = { add() {}, remove() {} }
+ append(...children) {
+ this.children.push(...children)
+ for (const child of children) child.parent = this
+ }
+ replaceChildren(...children) {
+ this.children = []
+ this.append(...children)
+ }
+ remove() {
+ if (this.parent)
+ this.parent.children = this.parent.children.filter((c) => c !== this)
+ }
+ setAttribute() {}
+ removeAttribute() {}
+ cloneNode() {
+ return new Element()
+ }
+ getContext() {
+ return { drawImage() {} }
+ }
+ animate(frames) {
+ let resolve, reject
+ const animation = {
+ frames,
+ finished: new Promise((a, b) => {
+ resolve = a
+ reject = b
+ }),
+ finish() {
+ resolve()
+ },
+ cancel() {
+ reject(new Error('cancelled'))
+ },
+ }
+ animations.push(animation)
+ return animation
+ }
+ }
+ globalThis.window = { addEventListener() {} }
+ globalThis.document = { createElement: () => new Element() }
+ t.onTestFinished(() => {
+ globalThis.window = originalWindow
+ globalThis.document = originalDocument
+ })
+ const city = {
+ reduceMotion: false,
+ controls: { enabled: true },
+ renderer: { domElement: new Element(), render() {} },
+ }
+ const panel = new Element(),
+ labels = new Element(),
+ slider = new CitySlider(city, panel, labels)
+ return { city, panel, slider, animations }
+}
+
+test('next and previous move scenes in opposite directions and unlock the map on completion', async (t) => {
+ const { city, panel, slider, animations } = setup(t)
+ for (const direction of [1, -1]) {
+ animations.length = 0
+ slider.capture()
+ const snapshot = slider.state.canvas
+ assert.equal(city.controls.enabled, false)
+ const done = slider.play(direction)
+ assert.equal(
+ animations[0].frames[1].transform,
+ `translateX(${-direction * 100}%)`,
+ )
+ assert.equal(
+ animations[1].frames[0].transform,
+ `translateX(${direction * 100}%)`,
+ )
+ assert.deepEqual(animations[1].frames, animations[2].frames)
+ for (const animation of animations) animation.finish()
+ await done
+ assert.equal(slider.active, false)
+ assert.equal(city.controls.enabled, true)
+ assert.equal(panel.children.length, 0)
+ assert.equal(snapshot.width, 0)
+ assert.equal(snapshot.height, 0)
+ }
+})
+
+test('canceling during filtering removes the old scene without ending a subsequent transition', async (t) => {
+ const { city, panel, slider, animations } = setup(t)
+ slider.capture()
+ const first = slider.play(1)
+ slider.cancel()
+ assert.equal(panel.children.length, 0)
+ assert.equal(city.controls.enabled, true)
+ slider.capture()
+ const next = slider.play(-1)
+ await first
+ assert.equal(slider.active, true)
+ assert.equal(panel.children.length, 1)
+ for (const animation of animations.slice(3)) animation.finish()
+ await next
+ assert.equal(slider.active, false)
+ assert.equal(panel.children.length, 0)
+})
+
+test('reduced motion switches the city without a snapshot or locking controls', async (t) => {
+ const { city, panel, slider, animations } = setup(t)
+ city.reduceMotion = true
+ slider.capture()
+ await slider.play(1)
+ assert.equal(animations.length, 0)
+ assert.equal(panel.children.length, 0)
+ assert.equal(city.controls.enabled, true)
+})
+
+test('snapshots preserve label CSS positions without changing the incoming city', (t) => {
+ const { panel, slider } = setup(t)
+ const rule = {
+ cssText:
+ '[data-atlas-city-instance="one"] .atlas-city-label[data-region="1"] { left: 10px; top: 20px; }',
+ }
+ panel.querySelector = () => ({ sheet: { cssRules: [rule] } })
+ slider.capture()
+ const style = slider.state.overlay.children.at(-1)
+ assert.match(style.textContent, /\.city-slide-labels \.atlas-city-label/)
+ assert.match(style.textContent, /left: 10px/)
+ rule.cssText = rule.cssText.replace('10px', '100px')
+ assert.match(style.textContent, /left: 10px/)
+ slider.cancel()
+ assert.equal(panel.children.length, 0)
+})
diff --git a/packages/react-charts/src/city/company-monitor.jsx b/packages/react-charts/src/city/company-monitor.jsx
new file mode 100644
index 00000000..ec16c5fc
--- /dev/null
+++ b/packages/react-charts/src/city/company-monitor.jsx
@@ -0,0 +1,159 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useMapI18n } from './use-map-i18n.js'
+import {
+ recentCriticalCompanies,
+ timestampValue,
+} from './engine/lib/company-dates.js'
+
+export function CompanyDate({ value }) {
+ const { locale, t } = useMapI18n()
+ if (!Number.isFinite(timestampValue(value)))
+ return {t('dateUnknown')}
+ return (
+
+ {new Intl.DateTimeFormat(locale, {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ }).format(new Date(value))}
+
+ )
+}
+
+function DigitalClock() {
+ const { locale, t } = useMapI18n()
+ const [now, setNow] = useState(null)
+ useEffect(() => {
+ const update = () => {
+ if (!document.hidden) setNow(new Date())
+ }
+ update()
+ const timer = setInterval(update, 1000)
+ document.addEventListener('visibilitychange', update)
+ return () => {
+ clearInterval(timer)
+ document.removeEventListener('visibilitychange', update)
+ }
+ }, [])
+ const clock = useMemo(
+ () =>
+ new Intl.DateTimeFormat(locale, {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hourCycle: 'h23',
+ }),
+ [locale],
+ )
+ const date = useMemo(
+ () =>
+ new Intl.DateTimeFormat(locale, {
+ dateStyle: 'long',
+ }),
+ [locale],
+ )
+ return (
+
+ {t('localClock')}
+
+ {now ? clock.format(now) : '--:--:--'}
+
+ {now ? date.format(now) : '—'}
+
+ )
+}
+
+function CompanyMetrics({ matching, pageSize, loading }) {
+ const { t, format } = useMapI18n()
+ return (
+
+
+
{t('companiesFound')}
+
+ {loading ? '—' : format(matching.length)}
+
+
+ {pageSize === 'all'
+ ? t('allInCity')
+ : t('companiesPerCity', { count: format(pageSize) })}
+
+
+
+ {['healthy', 'warning', 'critical'].map((status) => (
+
+
+
+ {loading
+ ? '—'
+ : format(
+ matching.filter((company) => company.status === status)
+ .length,
+ )}
+
+
{t(`status.${status}`)}
+
+ ))}
+
+
+ )
+}
+export function CompanyMonitor({
+ companies,
+ matching,
+ pageSize,
+ loading,
+ error,
+}) {
+ const { t, format } = useMapI18n()
+ const recent = useMemo(() => recentCriticalCompanies(companies), [companies])
+ return (
+
+
+
+
+ {t('recentCritical')}
+ {t('monitorScope')}
+ {loading || error ? (
+
+ {t(error ? 'monitorUnavailable' : 'monitorLoading')}
+
+ ) : (
+ <>
+
+ {recent.companies.map((company) => (
+
+ {company.name}
+
+
+ ))}
+
+ {!recent.total && (
+ {t('noCritical')}
+ )}
+ {recent.undatedCount > 0 && (
+
+ {t('criticalWithoutDate', {
+ count: format(recent.undatedCount),
+ })}
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/packages/react-charts/src/city/critical-blink.test.mjs b/packages/react-charts/src/city/critical-blink.test.mjs
new file mode 100644
index 00000000..a0092771
--- /dev/null
+++ b/packages/react-charts/src/city/critical-blink.test.mjs
@@ -0,0 +1,36 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import { createCriticalBlink } from './engine/critical-blink.js'
+
+test('critical companies have distinct rhythms and irregular intervals without gradual transitions', () => {
+ const patterns = []
+ for (let company = 0; company < 30; company++) {
+ const blink = createCriticalBlink(`empresa-${company}`),
+ changes = []
+ let previous = blink(0),
+ pattern = String(previous)
+ for (let frame = 1; frame <= 6000; frame++) {
+ const t = frame / 100,
+ on = blink(t)
+ assert.equal(typeof on, 'boolean')
+ if (on !== previous) {
+ changes.push(t)
+ previous = on
+ }
+ if (frame % 10 === 0) pattern += Number(on)
+ }
+ assert.ok(changes.length > 30)
+ const intervals = changes.slice(1).map((t, i) => t - changes[i])
+ assert.ok(intervals.every((d) => d >= 0.439 && d <= 1.111))
+ assert.ok(Math.max(...intervals) - Math.min(...intervals) > 0.2)
+ patterns.push(pattern)
+ }
+ assert.equal(new Set(patterns).size, patterns.length)
+})
+
+test('facade and roof keep the same rhythm after rebuilds and frame jumps', () => {
+ const body = createCriticalBlink('empresa-crítica'),
+ roof = createCriticalBlink('empresa-crítica')
+ for (const t of [0, 0.1, 0.8, 12, 85, 10000, 3])
+ assert.equal(body(t), roof(t))
+})
diff --git a/packages/react-charts/src/city/data.js b/packages/react-charts/src/city/data.js
new file mode 100644
index 00000000..daeb4c5d
--- /dev/null
+++ b/packages/react-charts/src/city/data.js
@@ -0,0 +1,61 @@
+import { REGIONS, STATUS, metricFor, placeCompanies } from './engine/model.js'
+import { normalizeCompanyDates } from './engine/lib/company-dates.js'
+
+/**
+ * @typedef {object} CityDatum
+ * @property {string|number} id Stable, unique company key.
+ * @property {string} name Company display name.
+ * @property {{label: string, value: number}} metric Nonnegative metric; encodes building height.
+ * @property {{label: string, value: string}[]} [categories] Generic district memberships.
+ * @property {1|2|3|4|5|6|7} [mask] Normalized category intersection; encodes district/color.
+ * @property {"healthy"|"warning"|"critical"} status Health classification.
+ * @property {string} [updatedAt] Last record update, ISO 8601 with timezone.
+ * @property {string} [statusChangedAt] Start of the current health status.
+ */
+
+/** Validate at the chart boundary, before layout or GPU allocation. */
+export function validateCityData(data) {
+ if (!Array.isArray(data))
+ throw new TypeError('cityMark: data deve ser uma lista.')
+ const ids = new Set()
+ for (const [index, datum] of data.entries()) {
+ if (
+ !datum ||
+ !['string', 'number'].includes(typeof datum.id) ||
+ !String(datum.id).trim() ||
+ ids.has(String(datum.id))
+ )
+ throw new TypeError(`cityMark: id ausente ou repetido na linha ${index}.`)
+ ids.add(String(datum.id))
+ }
+ let rows = data
+ if (data.some((datum) => datum.categories !== undefined)) {
+ try {
+ const normalized = placeCompanies(data)
+ if (data.some((datum) => datum.mask === undefined))
+ rows = normalized.map((datum, index) => ({
+ ...datum,
+ id: data[index].id,
+ }))
+ } catch (error) {
+ throw new TypeError(`cityMark: ${error.message}`)
+ }
+ }
+ for (const [index, datum] of rows.entries()) {
+ normalizeCompanyDates(datum, index)
+ if (
+ typeof datum.name !== 'string' ||
+ !datum.name.trim() ||
+ typeof metricFor(datum).label !== 'string' ||
+ !metricFor(datum).label.trim() ||
+ !Number.isFinite(metricFor(datum).value) ||
+ metricFor(datum).value < 0 ||
+ !REGIONS.some((region) => region.mask === datum.mask) ||
+ !Object.hasOwn(STATUS, datum.status)
+ )
+ throw new TypeError(
+ `cityMark: nome, métrica, categorias ou saúde inválidos na linha ${index}.`,
+ )
+ }
+ return rows
+}
diff --git a/packages/react-charts/src/city/definition.js b/packages/react-charts/src/city/definition.js
new file mode 100644
index 00000000..0c3192c6
--- /dev/null
+++ b/packages/react-charts/src/city/definition.js
@@ -0,0 +1,24 @@
+import { defineChart } from '@tanstack/charts'
+import { scaleLinear } from '@tanstack/charts/scales/linear'
+import { cityMark } from './mark.js'
+import { cityFocus } from './interaction.js'
+import { REGIONS } from './engine/model.js'
+
+/** A convenience preset; accepts the usual TanStack definition options. */
+export function defineCityChart(data, { layout, ...options } = {}) {
+ return defineChart({
+ marks: [cityMark(data, { layout })],
+ scales: {
+ x: { scale: scaleLinear, axis: false },
+ y: { scale: scaleLinear, axis: false },
+ },
+ color: {
+ domain: REGIONS.map((r) => r.mask),
+ range: REGIONS.map((r) => r.color),
+ },
+ guides: false,
+ margin: 0,
+ focus: cityFocus,
+ ...options,
+ })
+}
diff --git a/packages/react-charts/src/city/engine/city-slider.js b/packages/react-charts/src/city/engine/city-slider.js
new file mode 100644
index 00000000..dd16da28
--- /dev/null
+++ b/packages/react-charts/src/city/engine/city-slider.js
@@ -0,0 +1,100 @@
+// Keep only one live WebGL city; the departing city is a temporary snapshot.
+export class CitySlider {
+ constructor(city, panel, labels) {
+ this.city = city
+ this.panel = panel
+ this.labels = labels
+ this.state = null
+ this.onResize = () => this.cancel()
+ window.addEventListener('resize', this.onResize)
+ }
+ get active() {
+ return this.state !== null
+ }
+ dispose() {
+ this.cancel()
+ window.removeEventListener('resize', this.onResize)
+ }
+ capture() {
+ this.cancel()
+ if (this.city.reduceMotion || !this.panel.animate) return
+ const renderer = this.city.renderer,
+ source = renderer.domElement
+ const canvas = document.createElement('canvas')
+ canvas.width = source.clientWidth || source.width
+ canvas.height = source.clientHeight || source.height
+ const context = canvas.getContext('2d')
+ if (!context) return
+ renderer.render(this.city.scene, this.city.camera)
+ context.drawImage(source, 0, 0, canvas.width, canvas.height)
+ const overlay = document.createElement('div')
+ overlay.className = 'city-slide-snapshot'
+ overlay.setAttribute('aria-hidden', 'true')
+ const labels = this.labels.cloneNode(true)
+ labels.removeAttribute('id')
+ labels.className = 'city-slide-labels'
+ overlay.append(canvas, labels)
+ // Freeze the old labels' CSS positions while the live stylesheet moves
+ // to the next layout. The more specific rules leave the live labels alone.
+ const projectionSheet = this.panel.querySelector?.(
+ 'style[data-atlas-city-projections]',
+ )?.sheet
+ if (projectionSheet) {
+ const style = document.createElement('style')
+ style.textContent = Array.from(projectionSheet.cssRules, (rule) =>
+ rule.cssText.replace(
+ ' .atlas-city-label[',
+ ' .city-slide-labels .atlas-city-label[',
+ ),
+ ).join('\n')
+ overlay.append(style)
+ }
+ this.panel.append(overlay)
+ this.state = { overlay, canvas, animations: [] }
+ this.city.controls.enabled = false
+ this.panel.classList.add('city-sliding')
+ }
+ async play(direction) {
+ const state = this.state
+ if (!state) return
+ const options = {
+ duration: 650,
+ easing: 'cubic-bezier(.22,.68,.2,1)',
+ fill: 'both',
+ }
+ const incoming = [
+ { transform: `translateX(${direction * 100}%)` },
+ { transform: 'translateX(0)' },
+ ]
+ this.city.renderer.render(this.city.scene, this.city.camera)
+ state.animations = [
+ state.overlay.animate(
+ [
+ { transform: 'translateX(0)' },
+ { transform: `translateX(${-direction * 100}%)` },
+ ],
+ options,
+ ),
+ this.city.renderer.domElement.animate(incoming, options),
+ this.labels.animate(incoming, options),
+ ]
+ await Promise.allSettled(
+ state.animations.map((animation) => animation.finished),
+ )
+ if (this.state === state) this.cancel()
+ }
+ cancel() {
+ if (!this.state) return
+ const state = this.state
+ this.state = null
+ for (const animation of state.animations) animation.cancel()
+ state.animations.length = 0
+ state.overlay.remove()
+ state.overlay.replaceChildren()
+ state.canvas.width = 0
+ state.canvas.height = 0
+ this.panel.classList.remove('city-sliding')
+ this.city.controls.enabled = true
+ this.city.invalidate?.()
+ }
+}
diff --git a/packages/react-charts/src/city/engine/city.js b/packages/react-charts/src/city/engine/city.js
new file mode 100644
index 00000000..2a3d8c22
--- /dev/null
+++ b/packages/react-charts/src/city/engine/city.js
@@ -0,0 +1,473 @@
+import * as THREE from 'three'
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
+import { disposeSceneGroups } from './scene-resources.js'
+import { SelectionHighlight } from './selection-highlight.js'
+import { REGIONS, metricFor } from './model.js'
+import { buildTerrain } from '../three/terrain.js'
+import { buildBuildings } from '../three/buildings.js'
+import { buildTraffic, updateTraffic } from '../three/traffic.js'
+import { buildEffects, updateEffects } from '../three/effects.js'
+import { CityWeather } from '../three/weather.js'
+import {
+ advanceLighting,
+ buildLighting,
+ updateLighting,
+} from '../three/lighting.js'
+
+const ISO_POLAR = Math.acos(1 / Math.sqrt(3))
+export class City {
+ constructor(container, callbacks = {}) {
+ this.container = container
+ this.callbacks = callbacks
+ this.palettes = new Map(
+ REGIONS.map((r) => [r.mask, new THREE.Color(r.color)]),
+ )
+ this.active = new Set()
+ this.life = true
+ this.lightingMode = 'auto'
+ this.effects = true
+ this.autoRotate = false
+ this.weather = 'none'
+ this.running = true
+ this.elapsed = 0
+ this.previous = 0
+ this.cameraRevision = 0
+ this.animationId = null
+ this.animate = this.animate.bind(this)
+ this.reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
+ this.scene = new THREE.Scene()
+ this.scene.background = new THREE.Color('#e9ede4')
+ this.camera = new THREE.OrthographicCamera(-110, 110, 90, -90, 0.1, 800)
+ this.camera.position.set(160, 160, 160)
+ this.renderer = new THREE.WebGLRenderer({
+ antialias: true,
+ alpha: false,
+ powerPreference: 'high-performance',
+ })
+ this.renderer.setPixelRatio(Math.min(devicePixelRatio, 1.7))
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping
+ this.renderer.toneMappingExposure = 1.22
+ container.appendChild(this.renderer.domElement)
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement)
+ this.controls.enableDamping = true
+ this.controls.dampingFactor = 0.075
+ this.controls.minPolarAngle = ISO_POLAR
+ this.controls.maxPolarAngle = ISO_POLAR
+ this.controls.minZoom = 0.45
+ this.controls.maxZoom = 12
+ this.controls.zoomSpeed = 0.85
+ this.controls.rotateSpeed = 0.5
+ this.controls.autoRotateSpeed = -0.3 // Clockwise, one revolution in about 200 seconds.
+ this.controls.screenSpacePanning = true
+ this.controls.target.set(0, 0, 5)
+ this.controls.update()
+ buildLighting(this)
+ this.terrain = new THREE.Group()
+ this.scene.add(this.terrain)
+ this.buildings = new THREE.Group()
+ this.scene.add(this.buildings)
+ this.raycaster = new THREE.Raycaster()
+ this.pointer = new THREE.Vector2()
+ this.selection = new SelectionHighlight()
+ this.scene.add(this.selection)
+ this.onCameraChange = () => {
+ this.cameraRevision++
+ this.invalidate()
+ }
+ this.controls.addEventListener('change', this.onCameraChange)
+ this.observer = new ResizeObserver(() => this.resize())
+ this.observer.observe(container)
+ this.resize()
+ this.onVisibility = () => {
+ this.previous = 0
+ this.refreshLighting()
+ this.invalidate()
+ }
+ document.addEventListener('visibilitychange', this.onVisibility)
+ this.syncLightingClock()
+ this.invalidate()
+ }
+ resize() {
+ const w = this.container.clientWidth,
+ h = this.container.clientHeight
+ if (!w || !h) return
+ const sizeKey = `${w}:${h}:${this.homeHalf}`
+ if (this.sizeKey === sizeKey) return
+ this.sizeKey = sizeKey
+ const aspect = w / h
+ const half =
+ aspect < 1 ? (this.homeHalf ?? 32) / aspect : (this.homeHalf ?? 32)
+ this.camera.left = -half * aspect
+ this.camera.right = half * aspect
+ this.camera.top = half
+ this.camera.bottom = -half
+ this.camera.updateProjectionMatrix()
+ this.renderer.setSize(w, h)
+ this.cameraRevision++
+ this.invalidate()
+ }
+ buildTerrain() {
+ buildTerrain(this)
+ }
+ setCompanies(companies, layout, { colors } = {}) {
+ if (colors)
+ for (const [mask, color] of colors)
+ this.palettes.set(mask, new THREE.Color(color))
+ this.layout = layout
+ this.homeHalf = Math.max(
+ 18,
+ Math.max(
+ layout.roadBounds.x2 - layout.roadBounds.x1,
+ layout.roadBounds.z2 - layout.roadBounds.z1,
+ ) *
+ 2.8 *
+ 0.7,
+ )
+ this.resize()
+ disposeSceneGroups([this.terrain, this.traffic, this.buildings])
+ if (this.traffic) this.scene.remove(this.traffic)
+ this.renderer.renderLists.dispose()
+ this.warningTexture = null
+ this.plumes = null
+ this.simulation = null
+ this.body = null
+ this.windows = null
+ this.windowGlow = null
+ this.warning = null
+ this.criticalSurfaces = []
+ this.cars = null
+ this.carTops = null
+ this.walkers = null
+ this.heads = null
+ this.signalLamps = null
+ this.emitters = []
+ this.warningSources = []
+ this.lampItems = []
+ this.companies = []
+ this.buildTerrain()
+ this.buildTraffic()
+ this.companies = companies
+ this.active = new Set(companies.map((c) => c.id))
+ this.companiesById = new Map(companies.map((c) => [c.id, c]))
+ buildBuildings(this, companies)
+ this.weatherParticles?.setLayout(layout)
+ this.buildEffects()
+ this.selectedId = null
+ this.selection.visible = false
+ this.invalidate()
+ }
+ // Topology owns terrain/traffic. Metrics and health own only buildings/effects.
+ // The caller supplies private rows, so Three.js never mutates ChartPoint.datum.
+ updateCompanies(layout, colors) {
+ const next = layout.companies
+ const topologyChanged =
+ !this.layout ||
+ ['x1', 'x2', 'z1', 'z2'].some(
+ (k) => this.layout.roadBounds[k] !== layout.roadBounds[k],
+ ) ||
+ next.length !== this.companies.length ||
+ next.some((c, i) =>
+ ['id', 'mask', 'x', 'z', 'col', 'row'].some(
+ (k) => c[k] !== this.companies[i][k],
+ ),
+ )
+ if (topologyChanged) {
+ this.setCompanies(next, layout, { colors })
+ return true
+ }
+ const buildingsChanged =
+ JSON.stringify(layout.metricDomain) !==
+ JSON.stringify(this.layout.metricDomain) ||
+ next.some(
+ (c, i) =>
+ metricFor(c).value !== metricFor(this.companies[i]).value ||
+ c.status !== this.companies[i].status,
+ )
+ for (const [mask, color] of colors)
+ this.palettes.set(mask, new THREE.Color(color))
+ if (buildingsChanged) {
+ disposeSceneGroups([this.buildings])
+ this.renderer.renderLists.dispose()
+ this.warningTexture = null
+ this.companies = next
+ this.layout = layout
+ buildBuildings(this, next)
+ this.buildEffects()
+ } else {
+ next.forEach((c, i) => Object.assign(this.companies[i], c))
+ // Preserve per-building shading and the current critical blink phase.
+ for (const item of this.body.userData.items) {
+ const company = this.companiesById.get(item.company)
+ item.color
+ .copy(this.palettes.get(company.mask))
+ .multiplyScalar(item.shade)
+ }
+ this.body.userData.items.forEach((item, i) =>
+ this.body.setColorAt(i, item.color),
+ )
+ if (this.body.instanceColor) this.body.instanceColor.needsUpdate = true
+ for (const surface of this.criticalSurfaces)
+ for (const item of surface.items) {
+ item.color.copy(this.body.userData.items[item.index].color)
+ item.lastRed = undefined
+ }
+ }
+ this.effectsKey = null
+ this.layout = { ...layout, companies: this.companies }
+ this.weatherParticles?.setLayout(this.layout)
+ this.companiesById = new Map(this.companies.map((c) => [c.id, c]))
+ this.select(this.companiesById.get(this.selectedId))
+ this.invalidate()
+ return false
+ }
+ buildEffects() {
+ buildEffects(this)
+ }
+ updateEffects(t) {
+ updateEffects(this, t)
+ }
+ buildTraffic() {
+ buildTraffic(this)
+ }
+ updateTraffic(dt) {
+ updateTraffic(this, dt)
+ }
+ pick(e) {
+ if (!this.body) return null
+ const rect = this.renderer.domElement.getBoundingClientRect()
+ this.pointer.set(
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
+ (-(e.clientY - rect.top) / rect.height) * 2 + 1,
+ )
+ this.raycaster.setFromCamera(this.pointer, this.camera)
+ const hit = this.raycaster
+ .intersectObject(this.body, false)
+ .find(
+ (h) =>
+ h.instanceId !== undefined &&
+ this.active.has(this.companies[h.instanceId].id),
+ )
+ return hit ? this.companies[hit.instanceId] : null
+ }
+ select(c) {
+ this.selectedId = c?.id ?? null
+ if (c) this.selection.select(c)
+ else this.selection.visible = false
+ this.invalidate()
+ }
+ focus(c, { maxZoom = false } = {}) {
+ this.select(c)
+ const direction = this.camera.position
+ .clone()
+ .sub(this.controls.target)
+ .normalize()
+ const target = new THREE.Vector3(c.x, c.height * 0.45, c.z)
+ this.fly = {
+ fromPosition: this.camera.position.clone(),
+ toPosition: target.clone().add(direction.multiplyScalar(180)),
+ fromTarget: this.controls.target.clone(),
+ toTarget: target,
+ fromZoom: this.camera.zoom,
+ toZoom: maxZoom
+ ? this.controls.maxZoom
+ : Math.min(5, Math.max(1.8, this.homeHalf / 15)),
+ start: performance.now(),
+ }
+ this.invalidate?.()
+ }
+ view(mode = 'iso', immediate = false) {
+ // Flush pending drag damping so it cannot offset the restored view.
+ const damping = this.controls.enableDamping
+ this.controls.enableDamping = false
+ this.controls.update()
+ this.controls.enableDamping = damping
+ const [x, z] = this.layout?.center ?? [0, 0]
+ const target = new THREE.Vector3(x, 0, z),
+ pos =
+ mode === 'top'
+ ? new THREE.Vector3(x, 230, z + 0.001)
+ : new THREE.Vector3(x + 160, 160, z + 160)
+ // Dragging changes azimuth only; the view buttons choose the fixed elevation.
+ this.controls.minPolarAngle = this.controls.maxPolarAngle =
+ mode === 'top' ? 1e-6 : ISO_POLAR
+ if (immediate) {
+ this.fly = null
+ this.camera.position.copy(pos)
+ this.camera.zoom = 1
+ this.controls.target.copy(target)
+ this.controls.update()
+ this.camera.updateProjectionMatrix()
+ this.camera.updateMatrixWorld()
+ this.cameraRevision = (this.cameraRevision ?? 0) + 1
+ this.invalidate?.()
+ return
+ }
+ this.fly = {
+ fromPosition: this.camera.position.clone(),
+ toPosition: pos,
+ fromTarget: this.controls.target.clone(),
+ toTarget: target,
+ fromZoom: this.camera.zoom,
+ toZoom: 1,
+ start: performance.now(),
+ }
+ this.invalidate?.()
+ }
+ zoom(factor) {
+ this.fly = null
+ this.camera.zoom = THREE.MathUtils.clamp(
+ this.camera.zoom * factor,
+ 0.45,
+ 12,
+ )
+ this.camera.updateProjectionMatrix()
+ this.cameraRevision++
+ this.invalidate()
+ }
+ project(x, y, z) {
+ return new THREE.Vector3(x, y, z).project(this.camera)
+ }
+ dispose() {
+ this.running = false
+ clearInterval(this.lightingTimer)
+ cancelAnimationFrame(this.animationId)
+ this.observer.disconnect()
+ this.controls.removeEventListener('change', this.onCameraChange)
+ this.controls.dispose()
+ document.removeEventListener('visibilitychange', this.onVisibility)
+ disposeSceneGroups([this.scene])
+ this.renderer.renderLists.dispose()
+ this.renderer.dispose()
+ this.renderer.forceContextLoss()
+ this.renderer.domElement.remove()
+ this.callbacks = {}
+ }
+ // At most one pending frame. Static scenes sleep until an explicit change.
+ invalidate() {
+ if (!this.running) return
+ this.dirty = true
+ if (this.animationId === null && !document.hidden)
+ this.animationId = requestAnimationFrame(this.animate)
+ }
+ setActivity({ life = this.life, effects = this.effects }) {
+ if (this.life === life && this.effects === effects) return
+ this.life = life
+ this.effects = effects
+ this.refreshLighting()
+ this.syncLightingClock()
+ if (this.traffic) this.traffic.visible = life
+ this.previous = 0
+ this.invalidate()
+ }
+ setAutoRotate(enabled) {
+ if (this.autoRotate === enabled) return
+ this.autoRotate = enabled
+ this.previous = 0
+ this.invalidate()
+ }
+ refreshLighting() {
+ if (updateLighting(this)) this.invalidate()
+ }
+ setWeather(type = 'none') {
+ if (!['none', 'rain', 'snow'].includes(type))
+ throw new RangeError(`Unknown weather type: ${type}`)
+ if (this.weather === type) return
+ this.weather = type
+ if (type !== 'none' && !this.weatherParticles) {
+ this.weatherParticles = new CityWeather()
+ this.scene.add(this.weatherParticles)
+ if (this.layout) this.weatherParticles.setLayout(this.layout)
+ }
+ this.invalidate()
+ }
+ setLightingMode(mode = 'auto') {
+ if (!['auto', 'day', 'twilight', 'night'].includes(mode))
+ throw new RangeError(`Unknown lighting mode: ${mode}`)
+ if (this.lightingMode === mode) return
+ this.lightingMode = mode
+ this.refreshLighting()
+ this.syncLightingClock()
+ }
+ syncLightingClock() {
+ clearInterval(this.lightingTimer)
+ this.lightingTimer =
+ this.life && this.lightingMode === 'auto'
+ ? setInterval(() => {
+ if (!document.hidden) this.refreshLighting()
+ }, 60_000)
+ : null
+ }
+ animate(now) {
+ this.animationId = null
+ if (!this.running) return
+ if (document.hidden) {
+ this.previous = now
+ return
+ }
+ const dirty = this.dirty
+ this.dirty = false
+ const elapsedSeconds = (now - (this.previous || now)) / 1000
+ const dt = Math.min(elapsedSeconds, 0.05)
+ this.previous = now
+ if (!this.reduceMotion) this.elapsed += dt
+ const flying = Boolean(this.fly)
+ if (this.fly) {
+ const f = this.fly
+ // A RAF timestamp can precede a transition created later in that frame.
+ // Negative progress overshoots the easing curve, especially at 1 ms.
+ let t = THREE.MathUtils.clamp(
+ (now - f.start) / (this.reduceMotion ? 1 : 850),
+ 0,
+ 1,
+ )
+ t = t * t * (3 - 2 * t)
+ this.camera.position.lerpVectors(f.fromPosition, f.toPosition, t)
+ this.controls.target.lerpVectors(f.fromTarget, f.toTarget, t)
+ this.camera.zoom = THREE.MathUtils.lerp(f.fromZoom, f.toZoom, t)
+ this.camera.updateProjectionMatrix()
+ this.cameraRevision++
+ if (t >= 1) this.fly = null
+ }
+ // Explicit opt-in also works with reduced motion. OrbitControls pauses
+ // while dragging; camera flights and pagination take precedence as well.
+ this.controls.autoRotate = Boolean(
+ this.autoRotate && !flying && this.controls.enabled,
+ )
+ const cameraMoved = this.controls.update(elapsedSeconds)
+ this.controls.autoRotate = false
+ const lightingChanged = advanceLighting(this, now)
+ const trafficAnimated = !this.reduceMotion && this.life && this.simulation
+ const weatherAnimated =
+ !this.reduceMotion && this.life && this.weather !== 'none'
+ this.weatherParticles?.update(
+ this.elapsed,
+ this.weather,
+ weatherAnimated,
+ this.scene.background,
+ )
+ const effectsAnimated =
+ !this.reduceMotion &&
+ this.effects &&
+ (this.emitters?.length ||
+ this.warningSources?.length ||
+ this.criticalSurfaces?.some((s) => s.items.length))
+ if (trafficAnimated) this.updateTraffic(dt)
+ if (dirty || lightingChanged || effectsAnimated || cameraMoved)
+ this.updateEffects(this.elapsed)
+ this.camera.updateMatrixWorld()
+ this.callbacks.frame?.(this)
+ this.renderer.render(this.scene, this.camera)
+ if (
+ this.fly ||
+ (this.autoRotate && this.controls.enabled) ||
+ this.lightingTransition ||
+ cameraMoved ||
+ trafficAnimated ||
+ weatherAnimated ||
+ effectsAnimated
+ )
+ this.invalidate()
+ else this.previous = 0
+ }
+}
diff --git a/packages/react-charts/src/city/engine/critical-blink.js b/packages/react-charts/src/city/engine/critical-blink.js
new file mode 100644
index 00000000..6b5f26ab
--- /dev/null
+++ b/packages/react-charts/src/city/engine/critical-blink.js
@@ -0,0 +1,20 @@
+import { random } from './model.js'
+
+// Stable per company, independent of frame rate, pagination and mesh order.
+export function createCriticalBlink(companyId) {
+ let seed = 2166136261
+ for (const character of String(companyId))
+ seed = Math.imul(seed ^ character.codePointAt(0), 16777619)
+ const rng = random(seed),
+ ends = []
+ let period = 0
+ for (let i = 0; i < 32; i++) {
+ period += 0.45 + rng() * 0.65
+ ends.push(period)
+ }
+ const offset = rng() * period
+ return (time) => {
+ const local = (((time + offset) % period) + period) % period
+ return ends.findIndex((end) => local < end) % 2 === 0
+ }
+}
diff --git a/packages/react-charts/src/city/engine/landscape.js b/packages/react-charts/src/city/engine/landscape.js
new file mode 100644
index 00000000..1fe40a8f
--- /dev/null
+++ b/packages/react-charts/src/city/engine/landscape.js
@@ -0,0 +1,63 @@
+import { ROAD_EVERY, random } from './model.js'
+
+// Coordinate-seeded parks stay stable across renders, filters and pagination.
+// Clearings and small clusters break up the grid without occupying built lots.
+export function greenLots(layout) {
+ const occupied = new Set(layout.companies.map((c) => `${c.col},${c.row}`))
+ const lots = [],
+ bounds = layout.roadBounds
+ for (let row = bounds.z1; row <= bounds.z2; row++)
+ for (let col = bounds.x1; col <= bounds.x2; col++) {
+ if (
+ col % ROAD_EVERY === 0 ||
+ row % ROAD_EVERY === 0 ||
+ occupied.has(`${col},${row}`)
+ )
+ continue
+ // Coordinate seeds keep the same park looking the same after rebuilding.
+ const rng = random(Math.imul(col + 59, 7349) ^ Math.imul(row + 41, 9157))
+ const density = rng()
+ const count =
+ density < 0.18 ? 0 : density < 0.63 ? 1 : density < 0.92 ? 2 : 3
+ const benches = []
+ if (rng() < 0.09) {
+ const rotation = (Math.floor(rng() * 4) * Math.PI) / 2
+ benches.push({
+ dx: Math.sin(rotation) * 0.94,
+ dz: Math.cos(rotation) * 0.94,
+ rotation,
+ })
+ }
+ const trees = []
+ // Bounded rejection sampling leaves space around canopies and benches.
+ for (let attempt = 0; trees.length < count && attempt < 32; attempt++) {
+ const tree = {
+ dx: (rng() - 0.5) * 1.56,
+ dz: (rng() - 0.5) * 1.56,
+ scale: 0.32 + rng() * 0.18,
+ color: rng() > 0.5 ? '#789b59' : '#94af68',
+ }
+ if (
+ trees.some(
+ (other) =>
+ Math.hypot(tree.dx - other.dx, tree.dz - other.dz) <
+ tree.scale + other.scale + 0.08,
+ ) ||
+ benches.some(
+ (bench) =>
+ Math.hypot(tree.dx - bench.dx, tree.dz - bench.dz) <
+ tree.scale + 0.55,
+ )
+ )
+ continue
+ trees.push(tree)
+ }
+ lots.push({
+ col,
+ row,
+ trees,
+ benches,
+ })
+ }
+ return lots
+}
diff --git a/packages/react-charts/src/city/engine/lib/company-dates.js b/packages/react-charts/src/city/engine/lib/company-dates.js
new file mode 100644
index 00000000..615fe9ce
--- /dev/null
+++ b/packages/react-charts/src/city/engine/lib/company-dates.js
@@ -0,0 +1,63 @@
+// Source timestamps must identify an instant, including a timezone.
+export function timestampValue(value) {
+ if (typeof value !== 'string') return NaN
+ const parts =
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.exec(
+ value,
+ )
+ if (!parts) return NaN
+ const [, year, month, day, hour, minute, second] = parts.map(Number)
+ const days = new Date(Date.UTC(year, month, 0)).getUTCDate()
+ if (
+ month < 1 ||
+ month > 12 ||
+ day < 1 ||
+ day > days ||
+ hour > 23 ||
+ minute > 59 ||
+ second > 59
+ )
+ return NaN
+ return Date.parse(value)
+}
+
+export function normalizeCompanyDates(record, index) {
+ const dates = {}
+ for (const key of ['updatedAt', 'statusChangedAt']) {
+ if (record[key] == null) continue
+ const timestamp = timestampValue(record[key])
+ if (!Number.isFinite(timestamp))
+ throw new Error(
+ `Empresa ${index + 1}: ${key} deve ser uma data ISO 8601 válida com fuso horário.`,
+ )
+ dates[key] = new Date(timestamp).toISOString()
+ }
+ if (
+ dates.updatedAt &&
+ dates.statusChangedAt &&
+ dates.statusChangedAt > dates.updatedAt
+ )
+ throw new Error(
+ `Empresa ${index + 1}: statusChangedAt não pode ser posterior a updatedAt.`,
+ )
+ return dates
+}
+
+export function recentCriticalCompanies(companies) {
+ const critical = companies.filter((company) => company.status === 'critical')
+ const dated = critical.filter((company) =>
+ Number.isFinite(timestampValue(company.statusChangedAt)),
+ )
+ return {
+ companies: dated
+ .sort(
+ (a, b) =>
+ timestampValue(b.statusChangedAt) -
+ timestampValue(a.statusChangedAt) ||
+ String(a.id).localeCompare(String(b.id)),
+ )
+ .slice(0, 4),
+ undatedCount: critical.length - dated.length,
+ total: critical.length,
+ }
+}
diff --git a/packages/react-charts/src/city/engine/lib/fullscreen-wake-lock.js b/packages/react-charts/src/city/engine/lib/fullscreen-wake-lock.js
new file mode 100644
index 00000000..7201efd2
--- /dev/null
+++ b/packages/react-charts/src/city/engine/lib/fullscreen-wake-lock.js
@@ -0,0 +1,71 @@
+// Keep only this fullscreen map awake; release on exit, hiding or unmount.
+export function maintainFullscreenWakeLock({
+ document,
+ navigator,
+ element,
+ onStatus,
+}) {
+ let disposed = false
+ let lock = null
+ let pending = false
+ let generation = 0
+ const wanted = () =>
+ !disposed &&
+ Boolean(element()) &&
+ document.fullscreenElement === element() &&
+ document.visibilityState === 'visible'
+ const release = (sentinel) => {
+ if (sentinel) void sentinel.release().catch(() => {})
+ }
+ const sync = async () => {
+ if (!wanted()) {
+ generation++
+ const previous = lock
+ lock = null
+ release(previous)
+ if (!disposed) onStatus('idle')
+ return
+ }
+ if (lock || pending) return
+ if (!navigator.wakeLock?.request) {
+ onStatus('unavailable')
+ return
+ }
+ pending = true
+ const requestGeneration = generation
+ try {
+ const sentinel = await navigator.wakeLock.request('screen')
+ if (!wanted() || requestGeneration !== generation) {
+ release(sentinel)
+ } else {
+ lock = sentinel
+ onStatus(sentinel.released ? 'unavailable' : 'active')
+ sentinel.addEventListener(
+ 'release',
+ () => {
+ if (lock !== sentinel) return
+ lock = null
+ if (!disposed) onStatus(wanted() ? 'unavailable' : 'idle')
+ },
+ { once: true },
+ )
+ if (sentinel.released) lock = null
+ }
+ } catch {
+ if (wanted() && requestGeneration === generation) onStatus('unavailable')
+ } finally {
+ pending = false
+ // A hide/show or fullscreen exit/re-entry happened during the request.
+ if (!disposed && requestGeneration !== generation) void sync()
+ }
+ }
+ document.addEventListener('fullscreenchange', sync)
+ document.addEventListener('visibilitychange', sync)
+ void sync()
+ return () => {
+ disposed = true
+ document.removeEventListener('fullscreenchange', sync)
+ document.removeEventListener('visibilitychange', sync)
+ void sync()
+ }
+}
diff --git a/packages/react-charts/src/city/engine/lib/map-compass.js b/packages/react-charts/src/city/engine/lib/map-compass.js
new file mode 100644
index 00000000..14808671
--- /dev/null
+++ b/packages/react-charts/src/city/engine/lib/map-compass.js
@@ -0,0 +1,6 @@
+// North is -Z on the city's ground plane. Express it in camera space so
+// the arrow follows the visible streets, including their isometric tilt.
+export function mapNorthAngle(camera) {
+ const matrix = camera.matrixWorldInverse.elements
+ return Math.atan2(-matrix[8], -matrix[9])
+}
diff --git a/packages/react-charts/src/city/engine/lib/map-tooltip.js b/packages/react-charts/src/city/engine/lib/map-tooltip.js
new file mode 100644
index 00000000..7bde32f4
--- /dev/null
+++ b/packages/react-charts/src/city/engine/lib/map-tooltip.js
@@ -0,0 +1,23 @@
+// Keep the roof anchor available while a visible facade is hovered, even when
+// the roof itself projects beyond the map after zooming or panning.
+export function mapTooltipAnchor(point, width, height) {
+ if (
+ !Number.isFinite(point.x) ||
+ !Number.isFinite(point.y) ||
+ width <= 0 ||
+ height <= 0
+ )
+ return null
+ const insetX = Math.min(18, width / 2)
+ const insetY = Math.min(18, height / 2)
+ return {
+ x: Math.max(
+ insetX,
+ Math.min((point.x * 0.5 + 0.5) * width, width - insetX),
+ ),
+ y: Math.max(
+ insetY,
+ Math.min((-point.y * 0.5 + 0.5) * height, height - insetY),
+ ),
+ }
+}
diff --git a/packages/react-charts/src/city/engine/model.js b/packages/react-charts/src/city/engine/model.js
new file mode 100644
index 00000000..a9c0f9bf
--- /dev/null
+++ b/packages/react-charts/src/city/engine/model.js
@@ -0,0 +1,430 @@
+import { normalizeCompanyDates } from './lib/company-dates.js'
+
+export const REGIONS = [
+ {
+ mask: 1,
+ label: 'Category A',
+ shortLabel: 'A',
+ color: '#4b91ef',
+ name: 'Only Category A',
+ },
+ {
+ mask: 2,
+ label: 'Category B',
+ shortLabel: 'B',
+ color: '#e281af',
+ name: 'Only Category B',
+ },
+ {
+ mask: 4,
+ label: 'Category C',
+ shortLabel: 'C',
+ color: '#ecc456',
+ name: 'Only Category C',
+ },
+ {
+ mask: 3,
+ label: 'Category A + Category B',
+ shortLabel: 'A + B',
+ color: '#ab7cdd',
+ name: 'Category A e Category B',
+ },
+ {
+ mask: 5,
+ label: 'Category A + Category C',
+ shortLabel: 'A + C',
+ color: '#47b8b2',
+ name: 'Category A e Category C',
+ },
+ {
+ mask: 6,
+ label: 'Category B + Category C',
+ shortLabel: 'B + C',
+ color: '#e99b55',
+ name: 'Category B e Category C',
+ },
+ {
+ mask: 7,
+ label: 'Category A + Category B + Category C',
+ shortLabel: 'A + B + C',
+ color: '#7777ca',
+ name: 'Todos os sistemas',
+ },
+]
+export const STATUS = {
+ healthy: { label: 'Saudável', color: '#4c9878' },
+ warning: { label: 'Risco médio', color: '#c28b29' },
+ critical: { label: 'Alto risco', color: '#db654d' },
+}
+export function random(seed) {
+ let s = seed >>> 0
+ return () => {
+ s = (Math.imul(s, 1664525) + 1013904223) >>> 0
+ return s / 4294967296
+ }
+}
+export function membership(x, z) {
+ return (
+ (x >= -18 && x <= 18 && z >= -23 && z <= 7 ? 1 : 0) |
+ (x >= -29 && x <= 5 && z >= -6 && z <= 26 ? 2 : 0) |
+ (x >= -5 && x <= 29 && z >= -6 && z <= 26 ? 4 : 0)
+ )
+}
+export function isPark(x, z) {
+ const n = Math.imul(x + 59, 7349) ^ Math.imul(z + 41, 9157)
+ return (n >>> 0) % 100 < 9
+}
+export function lots() {
+ const result = []
+ for (let z = -23; z <= 26; z++)
+ for (let x = -29; x <= 29; x++) {
+ const mask = membership(x, z)
+ if (mask && x % 6 !== 0 && z % 6 !== 0 && !isPark(x, z))
+ result.push({ x: x * 2.8, z: z * 2.8, mask, col: x, row: z })
+ }
+ return result
+}
+const names = [
+ 'Aurora',
+ 'Horizonte',
+ 'Nexo',
+ 'Vértice',
+ 'Alameda',
+ 'Órbita',
+ 'Cedro',
+ 'Prisma',
+ 'Farol',
+ 'Solare',
+ 'Brisa',
+ 'Íris',
+ 'Ponte',
+ 'Lume',
+ 'Atlas',
+ 'Verde',
+]
+const sectors = [
+ 'Tecnologia',
+ 'Logística',
+ 'Educação',
+ 'Saúde',
+ 'Varejo',
+ 'Serviços',
+ 'Indústria',
+ 'Finanças',
+]
+export function demoCompanies(count = 1400) {
+ const rng = random(8419)
+ const pool = lots()
+ for (let i = pool.length - 1; i > 0; i--) {
+ const j = Math.floor(rng() * (i + 1))
+ ;[pool[i], pool[j]] = [pool[j], pool[i]]
+ }
+ const chosen = pool.slice(0, Math.min(count, pool.length))
+ return chosen.map((lot, i) => {
+ const risk = rng()
+ return {
+ id: `empresa-${i + 1}`,
+ name: `${names[i % names.length]} ${sectors[Math.floor(i / names.length) % sectors.length]} ${String(i + 1).padStart(4, '0')}`,
+ users: Math.round(8 + Math.pow(rng(), 3) * 4200),
+ status: risk < 0.024 ? 'critical' : risk < 0.1 ? 'warning' : 'healthy',
+ ...lot,
+ }
+ })
+}
+// Share one domain across the complete dataset, including filtered/paginated rows.
+export function metricDomainFor(records) {
+ let min = Infinity,
+ max = -Infinity
+ for (const record of records) {
+ const value = metricFor(record).value
+ min = Math.min(min, value)
+ max = Math.max(max, value)
+ }
+ return records.length ? [min, max] : [0, 0]
+}
+
+// Standalone legacy callers can omit the domain; cities always supply their data domain.
+export function sizeForValue(value, [min, max] = [0, 4200]) {
+ const ratio =
+ max === min
+ ? 0.5
+ : Math.sqrt(Math.max(0, Math.min(1, (value - min) / (max - min))))
+ const height = 0.95 + ratio * (16.8 - 0.95)
+ const floors = Math.min(14, Math.ceil(height / 1.2))
+ return { height, floors, house: floors === 1 }
+}
+// Compatibility for integrations using the previous helper name.
+export const sizeForUsers = sizeForValue
+export function filteredCompanies(
+ companies,
+ { region = 'all', status = 'all', query = '' } = {},
+) {
+ const q = query.trim().toLocaleLowerCase('pt-BR')
+ // Keep single-status callers compatible; an empty selection means all.
+ const statuses = Array.isArray(status)
+ ? status
+ : status === 'all'
+ ? []
+ : [status]
+ return companies.filter(
+ (c) =>
+ (region === 'all' || c.mask === Number(region)) &&
+ (!statuses.length || statuses.includes(c.status)) &&
+ (!q || c.name.toLocaleLowerCase('pt-BR').includes(q)),
+ )
+}
+// Legacy records remain readable; all visual consumers use the generic metric.
+export function metricFor(record) {
+ return record.metric ?? { label: 'Usuários', value: record.users }
+}
+export function categoryLabel(record) {
+ return (
+ record.categories?.map((category) => category.label).join(' + ') ??
+ REGIONS.find((region) => region.mask === record.mask)?.label ??
+ ''
+ )
+}
+export function metricLabel(records) {
+ const labels = new Set(records.map((record) => metricFor(record).label))
+ return labels.size === 1 ? [...labels][0] : 'Valor'
+}
+export function regionsFor(records) {
+ const labels = new Map()
+ for (const record of records) {
+ const bits = [1, 2, 4].filter((bit) => record.mask & bit)
+ record.categories?.forEach((category, index) =>
+ labels.set(bits[index], category.label),
+ )
+ }
+ if (!labels.size) return records.length ? REGIONS : []
+ return REGIONS.filter((region) =>
+ [1, 2, 4].every((bit) => !(region.mask & bit) || labels.has(bit)),
+ ).map((region) => {
+ const label = [1, 2, 4]
+ .filter((bit) => region.mask & bit)
+ .map((bit) => labels.get(bit))
+ .join(' + ')
+ return { ...region, label, shortLabel: label, name: label }
+ })
+}
+export function placeCompanies(records) {
+ if (!Array.isArray(records) || !records.length)
+ throw new Error('O arquivo deve conter uma lista de empresas.')
+ const ids = new Set()
+ const labels = new Map()
+ let hasLegacy = false
+ const normalized = records.map((r, i) => {
+ if (!r || typeof r.name !== 'string' || !r.name.trim())
+ throw new Error(`Empresa ${i + 1}: informe um nome.`)
+ const metric = metricFor(r)
+ if (
+ typeof metric.label !== 'string' ||
+ !metric.label.trim() ||
+ !Number.isFinite(metric.value) ||
+ metric.value < 0
+ )
+ throw new Error(
+ `Empresa ${i + 1}: metric deve conter label e value numérico não negativo.`,
+ )
+ let categories = r.categories
+ if (categories === undefined) {
+ hasLegacy = true
+ if (
+ !Array.isArray(r.systems) ||
+ !r.systems.length ||
+ r.systems.some((value) => !['A', 'B', 'C'].includes(value))
+ )
+ throw new Error(`Empresa ${i + 1}: sistemas deve conter A, B ou C.`)
+ categories = [...new Set(r.systems)].map((value) => ({
+ value,
+ label: REGIONS.find(
+ (region) => region.mask === { A: 1, B: 2, C: 4 }[value],
+ ).label,
+ }))
+ }
+ if (!Array.isArray(categories) || !categories.length)
+ throw new Error(`Empresa ${i + 1}: informe ao menos uma categoria.`)
+ const unique = new Map()
+ for (const category of categories) {
+ if (
+ !category ||
+ typeof category.value !== 'string' ||
+ !category.value.trim() ||
+ typeof category.label !== 'string' ||
+ !category.label.trim()
+ )
+ throw new Error(
+ `Empresa ${i + 1}: cada categoria deve conter label e value textuais não vazios.`,
+ )
+ const value = category.value.trim(),
+ label = category.label.trim()
+ if (labels.has(value) && labels.get(value) !== label)
+ throw new Error(`Categoria ${value}: use o mesmo label em toda a base.`)
+ labels.set(value, label)
+ unique.set(value, { value, label })
+ }
+ if (!Object.hasOwn(STATUS, r.status))
+ throw new Error(`Empresa ${i + 1}: status inválido.`)
+ const id = String(r.id ?? `importada-${i + 1}`)
+ if (!id.trim() || ids.has(id))
+ throw new Error('Há identificadores vazios ou repetidos no arquivo.')
+ ids.add(id)
+ return {
+ id,
+ name: r.name.trim(),
+ metric: { label: metric.label.trim(), value: metric.value },
+ categories: [...unique.values()],
+ status: r.status,
+ ...normalizeCompanyDates(r, i),
+ ...(r.metric === undefined ? { users: r.users } : {}),
+ }
+ })
+ const values = hasLegacy
+ ? [...new Set(['A', 'B', 'C', ...labels.keys()])]
+ : [...labels.keys()].sort()
+ if (values.length > 3)
+ throw new Error('O mapa aceita até três categorias distintas por base.')
+ const bits = new Map(values.map((value, index) => [value, 1 << index]))
+ return normalized.map((record) => ({
+ ...record,
+ categories: record.categories.sort(
+ (a, b) => bits.get(a.value) - bits.get(b.value),
+ ),
+ mask: record.categories.reduce(
+ (mask, category) => mask | bits.get(category.value),
+ 0,
+ ),
+ }))
+}
+export const PAGE_SIZE = 100
+export const PAGE_SIZES = [100, 250, 500, 1000, 'all']
+export const ROAD_EVERY = 4
+export function paginateCompanies(
+ companies,
+ filters = {},
+ page = 1,
+ pageSize = PAGE_SIZE,
+) {
+ const matching = filteredCompanies(companies, filters),
+ total = matching.length
+ if (!PAGE_SIZES.includes(pageSize))
+ throw new Error('Tamanho de página inválido.')
+ const limit = pageSize === 'all' ? Math.max(1, total) : pageSize
+ const pages = Math.max(1, Math.ceil(total / limit))
+ const current = Math.max(1, Math.min(pages, Math.floor(page) || 1))
+ return {
+ companies: matching.slice((current - 1) * limit, current * limit),
+ total,
+ pages,
+ page: current,
+ pageSize,
+ start: total ? (current - 1) * limit + 1 : 0,
+ end: Math.min(current * limit, total),
+ }
+}
+function boundsFor(p) {
+ return {
+ a: { x1: -p.cx - p.ab, x2: p.cx + p.ac, z1: -p.cz - p.a, z2: p.cz },
+ b: { x1: -p.cx - p.ab - p.b, x2: p.cx, z1: -p.cz, z2: p.cz + p.bc },
+ c: { x1: -p.cx, x2: p.cx + p.ac + p.c, z1: -p.cz, z2: p.cz + p.bc },
+ }
+}
+export function layoutMembership(x, z, bounds) {
+ const inRect = (r) => x >= r.x1 && x <= r.x2 && z >= r.z1 && z <= r.z2
+ return (
+ (inRect(bounds.a) ? 1 : 0) |
+ (inRect(bounds.b) ? 2 : 0) |
+ (inRect(bounds.c) ? 4 : 0)
+ )
+}
+export function layoutCity(records) {
+ const needed = new Map(
+ REGIONS.map((r) => [
+ r.mask,
+ records.filter((c) => c.mask === r.mask).length,
+ ]),
+ )
+ const core = Math.max(
+ 1,
+ Math.ceil(Math.sqrt(Math.max(0, ...needed.values())) / 2),
+ )
+ const p = { cx: core, cz: core, a: 1, b: 1, c: 1, ab: 1, ac: 1, bc: 1 }
+ let bounds, buckets
+ for (let attempt = 0; attempt < 250; attempt++) {
+ bounds = boundsFor(p)
+ buckets = new Map(REGIONS.map((r) => [r.mask, []]))
+ for (let z = bounds.a.z1; z <= bounds.b.z2; z++)
+ for (let x = bounds.b.x1; x <= bounds.c.x2; x++) {
+ const mask = layoutMembership(x, z, bounds)
+ if (
+ mask &&
+ x % ROAD_EVERY !== 0 &&
+ z % ROAD_EVERY !== 0 &&
+ !isPark(x, z)
+ )
+ buckets
+ .get(mask)
+ .push({ x: x * 2.8, z: z * 2.8, col: x, row: z, mask })
+ }
+ const missing = REGIONS.filter(
+ (r) => buckets.get(r.mask).length < needed.get(r.mask),
+ )
+ if (!missing.length) break
+ for (const r of missing) {
+ if (r.mask === 7) {
+ p.cx++
+ p.cz++
+ } else p[{ 1: 'a', 2: 'b', 4: 'c', 3: 'ab', 5: 'ac', 6: 'bc' }[r.mask]]++
+ }
+ }
+ // Within each exact intersection, populate the nearest free lots first.
+ for (const pool of buckets.values())
+ pool.sort(
+ (a, b) =>
+ a.col * a.col + a.row * a.row - (b.col * b.col + b.row * b.row) ||
+ a.row - b.row ||
+ a.col - b.col,
+ )
+ const placed = records.map((record) => {
+ const lot = buckets.get(record.mask).shift()
+ if (!lot) throw new Error('Não foi possível distribuir esta página.')
+ return { ...record, ...lot }
+ })
+ const roadBounds = placed.length
+ ? {
+ x1:
+ Math.floor((Math.min(...placed.map((c) => c.col)) - 2) / ROAD_EVERY) *
+ ROAD_EVERY,
+ x2:
+ Math.ceil((Math.max(...placed.map((c) => c.col)) + 2) / ROAD_EVERY) *
+ ROAD_EVERY,
+ z1:
+ Math.floor((Math.min(...placed.map((c) => c.row)) - 2) / ROAD_EVERY) *
+ ROAD_EVERY,
+ z2:
+ Math.ceil((Math.max(...placed.map((c) => c.row)) + 2) / ROAD_EVERY) *
+ ROAD_EVERY,
+ }
+ : { x1: -4, x2: 4, z1: -4, z2: 4 }
+ const anchors = {}
+ for (const r of REGIONS) {
+ const group = placed.filter((c) => c.mask === r.mask)
+ if (group.length)
+ anchors[r.mask] = [
+ group.reduce((v, c) => v + c.x, 0) / group.length,
+ 2,
+ group.reduce((v, c) => v + c.z, 0) / group.length,
+ ]
+ }
+ return {
+ companies: placed,
+ metricDomain: metricDomainFor(records),
+ bounds,
+ roadBounds,
+ anchors,
+ center: [
+ (roadBounds.x1 + roadBounds.x2) * 1.4,
+ (roadBounds.z1 + roadBounds.z2) * 1.4,
+ ],
+ }
+}
diff --git a/packages/react-charts/src/city/engine/plume-renderer.js b/packages/react-charts/src/city/engine/plume-renderer.js
new file mode 100644
index 00000000..2d376c4c
--- /dev/null
+++ b/packages/react-charts/src/city/engine/plume-renderer.js
@@ -0,0 +1,116 @@
+import * as THREE from 'three'
+import { makeParticles, samplePlume } from './plumes.js'
+
+const vertexShader = `
+attribute float aAlpha;attribute float aHeat;attribute float aSeed;
+varying float vAlpha;varying float vHeat;varying float vSeed;
+varying vec3 vNormal;varying vec3 vView;varying vec3 vLocal;
+void main(){
+ vAlpha=aAlpha;vHeat=aHeat;vSeed=aSeed;vLocal=position;
+ vec4 mv=modelViewMatrix*instanceMatrix*vec4(position,1.);
+ vec3 s=vec3(dot(instanceMatrix[0].xyz,instanceMatrix[0].xyz),dot(instanceMatrix[1].xyz,instanceMatrix[1].xyz),dot(instanceMatrix[2].xyz,instanceMatrix[2].xyz));
+ vNormal=normalMatrix*mat3(instanceMatrix)*(normal/max(s,vec3(.000001)));vView=-mv.xyz;
+ gl_Position=projectionMatrix*mv;
+}`
+const fragmentShader = `
+uniform float uTime;uniform bool uSmoke;
+varying float vAlpha;varying float vHeat;varying float vSeed;
+varying vec3 vNormal;varying vec3 vView;varying vec3 vLocal;
+void main(){
+ float facing=pow(max(0.,dot(normalize(vNormal),normalize(vView))),.65);
+ float billow=.73+.27*sin(vLocal.x*8.+vSeed+uTime*.7)*sin(vLocal.z*7.-vLocal.y*5.+vSeed);
+ float alpha=vAlpha*facing*billow;
+ if(alpha<.005)discard;
+ vec3 color;
+ if(uSmoke){float shade=mix(.035,.55,vHeat);color=vec3(shade*.97,shade,shade*.98)*(1.-.13*vLocal.y);}
+ else{float core=clamp(vHeat*.78+facing*.32,0.,1.);color=mix(vec3(.98,.10,.006),vec3(1.,.93,.53),core*core);}
+ gl_FragColor=vec4(color,alpha);
+ #include
+ #include
+}`
+
+export class WindowPlumes {
+ constructor(emitters, parent) {
+ this.parent = parent
+ this.layers = []
+ this.dummy = new THREE.Object3D()
+ this.velocity = new THREE.Vector3()
+ this.up = new THREE.Vector3(0, 1, 0)
+ for (const kind of ['flame', 'smoke']) {
+ const particles = makeParticles(emitters, kind),
+ geometry = new THREE.OctahedronGeometry(1, 0)
+ const n = Math.max(1, particles.length)
+ geometry.setAttribute(
+ 'aAlpha',
+ new THREE.InstancedBufferAttribute(new Float32Array(n), 1).setUsage(
+ THREE.DynamicDrawUsage,
+ ),
+ )
+ geometry.setAttribute(
+ 'aHeat',
+ new THREE.InstancedBufferAttribute(new Float32Array(n), 1).setUsage(
+ THREE.DynamicDrawUsage,
+ ),
+ )
+ geometry.setAttribute(
+ 'aSeed',
+ new THREE.InstancedBufferAttribute(new Float32Array(n), 1),
+ )
+ particles.forEach((p, i) => (geometry.attributes.aSeed.array[i] = p.seed))
+ const material = new THREE.ShaderMaterial({
+ vertexShader,
+ fragmentShader,
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ toneMapped: false,
+ uniforms: { uTime: { value: 0 }, uSmoke: { value: kind === 'smoke' } },
+ })
+ const mesh = new THREE.InstancedMesh(geometry, material, n)
+ mesh.count = particles.length
+ mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
+ mesh.frustumCulled = false
+ mesh.renderOrder = kind === 'smoke' ? 4 : 3
+ parent.add(mesh)
+ this.layers.push({ kind, particles, mesh })
+ }
+ }
+ update(time, visible, active) {
+ for (const layer of this.layers) {
+ const { mesh, particles, kind } = layer
+ mesh.visible = visible
+ if (!visible) continue
+ mesh.material.uniforms.uTime.value = time
+ const alpha = mesh.geometry.attributes.aAlpha,
+ heat = mesh.geometry.attributes.aHeat
+ particles.forEach((particle, i) => {
+ const age =
+ (time + particle.phase + particle.source.seed) % particle.life
+ const state = samplePlume(particle.source, age, particle.seed, kind)
+ this.dummy.position.set(state.x, state.y, state.z)
+ if (kind === 'flame') {
+ this.velocity.set(state.vx, state.vy, state.vz).normalize()
+ this.dummy.quaternion.setFromUnitVectors(this.up, this.velocity)
+ } else
+ this.dummy.rotation.set(
+ particle.seed + time * 0.06,
+ particle.seed * 0.7,
+ particle.seed * 0.3,
+ )
+ this.dummy.scale.set(state.radius, state.length, state.radius)
+ this.dummy.updateMatrix()
+ mesh.setMatrixAt(i, this.dummy.matrix)
+ alpha.array[i] = active.has(particle.source.company) ? state.alpha : 0
+ heat.array[i] =
+ kind === 'smoke'
+ ? particle.source.critical
+ ? 0.12 + (age / particle.life) * 0.36
+ : 0.82
+ : state.heat
+ })
+ mesh.instanceMatrix.needsUpdate = true
+ alpha.needsUpdate = true
+ heat.needsUpdate = true
+ }
+ }
+}
diff --git a/packages/react-charts/src/city/engine/plumes.js b/packages/react-charts/src/city/engine/plumes.js
new file mode 100644
index 00000000..3c44e813
--- /dev/null
+++ b/packages/react-charts/src/city/engine/plumes.js
@@ -0,0 +1,145 @@
+const fract = (n) => n - Math.floor(n)
+export const hash = (n) => fract(Math.sin(n * 127.1 + 311.7) * 43758.5453)
+
+export function facadeWindows(company, size, width, depth) {
+ const windows = []
+ for (let floor = 0; floor < size.floors; floor++)
+ for (let j = 0; j < 2; j++) {
+ const offset = (j - 0.5) * 0.62,
+ y = 0.52 + floor * (company.height / size.floors)
+ windows.push({
+ index: windows.length,
+ x: company.x + offset,
+ y,
+ z: company.z + depth / 2,
+ nx: 0,
+ nz: 1,
+ width: 0.25,
+ height: 0.35,
+ })
+ windows.push({
+ index: windows.length,
+ x: company.x + width / 2,
+ y,
+ z: company.z + offset,
+ nx: 1,
+ nz: 0,
+ width: 0.25,
+ height: 0.35,
+ })
+ }
+ return windows
+}
+
+export function windowEmitters(company, windows) {
+ if (company.status === 'healthy') return []
+ const critical = company.status === 'critical'
+ const floors = new Set(windows.map((w) => w.y)).size
+ const count = Math.min(
+ windows.length,
+ critical
+ ? company.house
+ ? 2
+ : 6
+ : company.house
+ ? 1
+ : Math.max(2, Math.ceil(floors * 0.75)),
+ )
+ return Array.from({ length: count }, (_, i) => {
+ const index = critical
+ ? Math.floor((i * windows.length) / count) + (i % 2)
+ : Math.floor((i * (floors - 1)) / Math.max(1, count - 1)) * 4 + (i % 4)
+ const aperture = windows[Math.min(windows.length - 1, index)]
+ // Origin is behind the facade; the building depth test hides the interior.
+ const seed =
+ hash(company.x * 13 + company.z * 71 + aperture.index * 17) * 100
+ return {
+ ...aperture,
+ company: company.id,
+ critical,
+ seed,
+ depth: 0.075,
+ flameLife: 1.25,
+ smokeLife: critical ? 5.2 : 5.8,
+ rate: aperture.width * aperture.height * (critical ? 225 : 34),
+ }
+ })
+}
+
+// An inexpensive buoyant jet approximation: pressure drives the initial outward
+// velocity, drag dissipates it, buoyancy bends the stream upward. This is not CFD.
+// All births fit the aperture; expansion starts only outside the opening.
+export function samplePlume(emitter, age, seed, kind = 'flame') {
+ const smoke = kind === 'smoke',
+ critical = emitter.critical
+ const life = smoke ? emitter.smokeLife : emitter.flameLife,
+ t = Math.max(0, Math.min(age, life))
+ const a = hash(seed + 11),
+ b = hash(seed + 23),
+ c = hash(seed + 37)
+ const drag = smoke ? 0.75 : 1.15,
+ velocity = (critical ? 1.05 : 0.55) * (0.8 + a * 0.4)
+ const out = -emitter.depth + (velocity * (1 - Math.exp(-drag * t))) / drag
+ const outside = Math.max(0, out)
+ const force = smoke ? (critical ? 1.65 : 0.92) : 3.2
+ const rise =
+ 0.07 * t + force * (t / drag - (1 - Math.exp(-drag * t)) / (drag * drag))
+ const apertureRadius = Math.min(emitter.width, emitter.height) * 0.13
+ const initialSide = (b - 0.5) * (emitter.width - 2 * apertureRadius) * 0.66
+ const initialY = (c - 0.5) * (emitter.height - 2 * apertureRadius) * 0.55
+ let tangent =
+ initialSide +
+ outside * Math.sin(t * (smoke ? 3.1 : 11) + seed) * (smoke ? 0.22 : 0.1)
+ let y = initialY + rise
+ if (out < 0.035) {
+ tangent = Math.max(
+ -emitter.width * 0.34,
+ Math.min(emitter.width * 0.34, tangent),
+ )
+ y = Math.max(-emitter.height * 0.3, Math.min(emitter.height * 0.3, y))
+ }
+ const expansion = smoke
+ ? outside * 0.27 + Math.max(0, t - 0.35) * 0.15
+ : outside * 0.16 * Math.sin(Math.PI * Math.min(0.98, t / life))
+ const radius = out < 0.035 ? apertureRadius : apertureRadius + expansion
+ const wind = smoke ? Math.pow(Math.max(0, t - 0.15), 1.35) : 0
+ const heat = Math.pow(Math.max(0, 1 - t / life), 0.65)
+ const alpha =
+ (smoke ? (critical ? 0.35 : 0.23) : 0.84) *
+ Math.min(1, t / (smoke ? 0.18 : 0.055)) *
+ Math.pow(Math.max(0, 1 - t / life), smoke ? 0.7 : 0.6)
+ return {
+ x: emitter.x + emitter.nx * out - emitter.nz * tangent + wind * 0.035,
+ y: emitter.y + y,
+ z: emitter.z + emitter.nz * out + emitter.nx * tangent + wind * 0.018,
+ radius,
+ length: out < 0.035 ? apertureRadius : radius * (smoke ? 1.1 : 2.4),
+ alpha,
+ heat,
+ vx: emitter.nx * velocity * Math.exp(-drag * t),
+ vy: 0.07 + (force * (1 - Math.exp(-drag * t))) / drag,
+ vz: emitter.nz * velocity * Math.exp(-drag * t),
+ outward: out,
+ }
+}
+
+export function makeParticles(emitters, kind) {
+ const smoke = kind === 'smoke',
+ particles = []
+ for (const source of emitters) {
+ if (!smoke && !source.critical) continue
+ const life = smoke ? source.smokeLife : source.flameLife
+ const rate = smoke
+ ? source.width * source.height * (source.critical ? 55 : 45)
+ : source.rate
+ const count = Math.max(8, Math.ceil(rate * life))
+ for (let i = 0; i < count; i++)
+ particles.push({
+ source,
+ seed: source.seed + i * 3.17,
+ phase: (i / count) * life,
+ life,
+ })
+ }
+ return particles
+}
diff --git a/packages/react-charts/src/city/engine/scene-resources.js b/packages/react-charts/src/city/engine/scene-resources.js
new file mode 100644
index 00000000..1afbbd52
--- /dev/null
+++ b/packages/react-charts/src/city/engine/scene-resources.js
@@ -0,0 +1,26 @@
+// Shared geometries/materials/textures are released once, before the next city.
+export function disposeSceneGroups(groups) {
+ const geometries = new Set(),
+ materials = new Set(),
+ textures = new Set()
+ for (const group of groups) {
+ if (!group) continue
+ group.traverse((object) => {
+ object.dispose?.()
+ if (object.geometry) geometries.add(object.geometry)
+ for (const material of object.material
+ ? Array.isArray(object.material)
+ ? object.material
+ : [object.material]
+ : []) {
+ materials.add(material)
+ for (const value of Object.values(material))
+ if (value?.isTexture) textures.add(value)
+ }
+ })
+ group.clear()
+ }
+ for (const texture of textures) texture.dispose()
+ for (const material of materials) material.dispose()
+ for (const geometry of geometries) geometry.dispose()
+}
diff --git a/packages/react-charts/src/city/engine/selection-highlight.js b/packages/react-charts/src/city/engine/selection-highlight.js
new file mode 100644
index 00000000..c26985b1
--- /dev/null
+++ b/packages/react-charts/src/city/engine/selection-highlight.js
@@ -0,0 +1,54 @@
+import * as THREE from 'three'
+
+// Reuse one highlight for every selection, without recoloring company/status data.
+export class SelectionHighlight extends THREE.Group {
+ constructor() {
+ super()
+ this.visible = false
+ this.box = new THREE.BoxGeometry(1, 1, 1)
+ this.shells = []
+ for (const [color, padding, order] of [
+ ['#123441', 0.22, 1],
+ ['#ffffff', 0.12, 2],
+ ]) {
+ const material = new THREE.MeshBasicMaterial({
+ color,
+ side: THREE.BackSide,
+ depthWrite: false,
+ toneMapped: false,
+ })
+ const body = new THREE.Mesh(this.box, material)
+ body.renderOrder = order
+ this.add(body)
+ this.shells.push({ body, padding })
+ }
+ for (const [color, inner, outer, y] of [
+ ['#123441', 1.58, 1.94, 0.17],
+ ['#ffffff', 1.66, 1.85, 0.18],
+ ]) {
+ const ring = new THREE.Mesh(
+ new THREE.RingGeometry(inner, outer, 4),
+ new THREE.MeshBasicMaterial({
+ color,
+ side: THREE.DoubleSide,
+ toneMapped: false,
+ }),
+ )
+ ring.rotation.set(-Math.PI / 2, 0, Math.PI / 4)
+ ring.position.y = y
+ this.add(ring)
+ }
+ }
+ select(company) {
+ this.position.set(company.x, 0, company.z)
+ this.visible = true
+ for (const { body, padding } of this.shells) {
+ body.position.set(0, company.height / 2 + 0.13, 0)
+ body.scale.set(
+ company.width + padding,
+ company.height + padding,
+ company.depth + padding,
+ )
+ }
+ }
+}
diff --git a/packages/react-charts/src/city/engine/traffic.js b/packages/react-charts/src/city/engine/traffic.js
new file mode 100644
index 00000000..a453c8c5
--- /dev/null
+++ b/packages/react-charts/src/city/engine/traffic.js
@@ -0,0 +1,331 @@
+import { random, ROAD_EVERY } from './model.js'
+
+export const ROAD = {
+ step: 2.8,
+ width: 2.15,
+ lane: 0.5,
+ sidewalk: 1.29,
+ crosswalk: 1.8,
+ stop: 3.42,
+ gateHalf: 2.75,
+ carLength: 0.95,
+ carWidth: 0.58,
+ minGap: 2.0,
+}
+const mod = (n, d) => ((n % d) + d) % d
+const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
+
+// The intersection has a full horizontal strip and two non-overlapping vertical
+// connectors. Every street meets the next tile at its full 2.8-unit edge.
+export function roadTileParts(col, row) {
+ const vertical = col % ROAD_EVERY === 0,
+ horizontal = row % ROAD_EVERY === 0
+ if (!vertical && !horizontal) return []
+ if (vertical && horizontal) {
+ const gap = (ROAD.step - ROAD.width) / 2
+ return [
+ { dx: 0, dz: 0, w: ROAD.step, d: ROAD.width },
+ { dx: 0, dz: (ROAD.width + gap) / 2, w: ROAD.width, d: gap },
+ { dx: 0, dz: -(ROAD.width + gap) / 2, w: ROAD.width, d: gap },
+ ]
+ }
+ return [
+ {
+ dx: 0,
+ dz: 0,
+ w: vertical ? ROAD.width : ROAD.step,
+ d: horizontal ? ROAD.width : ROAD.step,
+ },
+ ]
+}
+
+export function signalState(time) {
+ const t = mod(time, 42)
+ if (t < 9) return { x: 'green', z: 'red', walk: false, remaining: 9 - t }
+ if (t < 11) return { x: 'amber', z: 'red', walk: false, remaining: 11 - t }
+ if (t < 16) return { x: 'red', z: 'red', walk: false, remaining: 16 - t }
+ if (t < 25) return { x: 'red', z: 'green', walk: false, remaining: 25 - t }
+ if (t < 27) return { x: 'red', z: 'amber', walk: false, remaining: 27 - t }
+ if (t < 32) return { x: 'red', z: 'red', walk: false, remaining: 32 - t }
+ if (t < 39) return { x: 'red', z: 'red', walk: true, remaining: 39 - t }
+ return { x: 'red', z: 'red', walk: false, remaining: 42 - t }
+}
+
+export function makeLoop(x0, z0, x1, z1, offset = ROAD.lane, radius = 0.75) {
+ const l = x0 + offset,
+ r = x1 - offset,
+ t = z0 + offset,
+ b = z1 - offset
+ const w = r - l - 2 * radius,
+ h = b - t - 2 * radius,
+ arc = (radius * Math.PI) / 2
+ const segments = [
+ { length: w, x: l + radius, z: t, dx: 1, dz: 0 },
+ { length: arc, cx: r - radius, cz: t + radius, angle: -Math.PI / 2 },
+ { length: h, x: r, z: t + radius, dx: 0, dz: 1 },
+ { length: arc, cx: r - radius, cz: b - radius, angle: 0 },
+ { length: w, x: r - radius, z: b, dx: -1, dz: 0 },
+ { length: arc, cx: l + radius, cz: b - radius, angle: Math.PI / 2 },
+ { length: h, x: l, z: b - radius, dx: 0, dz: -1 },
+ { length: arc, cx: l + radius, cz: t + radius, angle: Math.PI },
+ ]
+ let distance = 0
+ const corners = []
+ for (let i = 0; i < segments.length; i++) {
+ segments[i].start = distance
+ if (i % 2) corners.push(distance + arc / 2)
+ distance += segments[i].length
+ }
+ return { segments, radius, length: distance, corners, x0, z0, x1, z1 }
+}
+
+export function loopPoint(loop, distance) {
+ const s = mod(distance, loop.length)
+ const segment =
+ loop.segments.find((e) => s < e.start + e.length) || loop.segments.at(-1)
+ const u = s - segment.start
+ if (segment.cx !== undefined) {
+ const a = segment.angle + u / loop.radius
+ return {
+ x: segment.cx + Math.cos(a) * loop.radius,
+ z: segment.cz + Math.sin(a) * loop.radius,
+ ry: Math.atan2(-Math.sin(a), Math.cos(a)),
+ }
+ }
+ return {
+ x: segment.x + segment.dx * u,
+ z: segment.z + segment.dz * u,
+ ry: Math.atan2(segment.dx, segment.dz),
+ }
+}
+
+export class TrafficSimulation {
+ constructor(bounds, seed = 529) {
+ this.time = 0
+ this.intersections = []
+ this.routes = []
+ this.cars = []
+ this.walkers = []
+ this.rng = random(seed)
+ const nodes = new Map(),
+ key = (x, z) => `${x},${z}`
+ for (let z = bounds.z1; z <= bounds.z2; z += ROAD_EVERY)
+ for (let x = bounds.x1; x <= bounds.x2; x += ROAD_EVERY) {
+ const node = {
+ id: key(x, z),
+ x: x * ROAD.step,
+ z: z * ROAD.step,
+ owner: null,
+ pedestrians: 0,
+ }
+ nodes.set(node.id, node)
+ this.intersections.push(node)
+ }
+ const blocks = []
+ for (let z = bounds.z1; z < bounds.z2; z += ROAD_EVERY)
+ for (let x = bounds.x1; x < bounds.x2; x += ROAD_EVERY)
+ blocks.push({ x, z })
+ for (let i = blocks.length - 1; i > 0; i--) {
+ const j = Math.floor(this.rng() * (i + 1))
+ ;[blocks[i], blocks[j]] = [blocks[j], blocks[i]]
+ }
+ for (const { x, z } of blocks.slice(0, 18)) {
+ const loop = makeLoop(
+ x * ROAD.step,
+ z * ROAD.step,
+ (x + ROAD_EVERY) * ROAD.step,
+ (z + ROAD_EVERY) * ROAD.step,
+ )
+ loop.nodes = [
+ nodes.get(key(x + ROAD_EVERY, z)),
+ nodes.get(key(x + ROAD_EVERY, z + ROAD_EVERY)),
+ nodes.get(key(x, z + ROAD_EVERY)),
+ nodes.get(key(x, z)),
+ ]
+ loop.gates = loop.corners.map((center, i) => ({
+ entry: mod(center - ROAD.gateHalf, loop.length),
+ node: loop.nodes[i],
+ axis: i % 2 ? 'z' : 'x',
+ }))
+ this.routes.push(loop)
+ for (let j = 0; j < 2; j++) {
+ const s = loop.segments[0].length * 0.4 + (j * loop.length) / 2
+ const car = {
+ id: this.cars.length,
+ route: loop,
+ s,
+ speed: 0,
+ maxSpeed: 1.65 + this.rng() * 0.35,
+ nextGate: j * 2,
+ reservation: null,
+ distance: 0,
+ }
+ Object.assign(car, loopPoint(loop, s))
+ this.cars.push(car)
+ }
+ const footLoop = makeLoop(
+ x * ROAD.step,
+ z * ROAD.step,
+ (x + ROAD_EVERY) * ROAD.step,
+ (z + ROAD_EVERY) * ROAD.step,
+ ROAD.sidewalk,
+ 0.12,
+ )
+ for (let j = 0; j < 2; j++) {
+ const s = this.rng() * footLoop.length
+ this.walkers.push({
+ kind: 'stroll',
+ route: footLoop,
+ s,
+ speed: 0.26 + this.rng() * 0.16,
+ ...loopPoint(footLoop, s),
+ })
+ }
+ }
+ // A pedestrian scramble phase: cars are red in both directions. Pedestrians
+ // only enter when the whole junction is clear, and retain it until exit.
+ const crossingNodes = [...this.intersections]
+ for (let i = 0; i < Math.min(24, crossingNodes.length); i++) {
+ const node =
+ crossingNodes[
+ Math.floor(
+ (i * crossingNodes.length) / Math.min(24, crossingNodes.length),
+ )
+ ]
+ const axis = i % 2 ? 'x' : 'z'
+ const edge =
+ axis === 'z'
+ ? node.x >= bounds.x2 * ROAD.step
+ ? -1
+ : 1
+ : node.z >= bounds.z2 * ROAD.step
+ ? -1
+ : 1
+ const walker = {
+ kind: 'cross',
+ node,
+ axis,
+ edge,
+ progress: 0,
+ direction: 1,
+ state: 'waiting',
+ wait: this.rng() * 7,
+ speed: 0.52 + this.rng() * 0.08,
+ }
+ this.positionCrossing(walker)
+ this.walkers.push(walker)
+ }
+ }
+ positionCrossing(w) {
+ const cross = (w.progress * 2 - 1) * ROAD.sidewalk
+ w.x = w.node.x + (w.axis === 'x' ? cross : w.edge * ROAD.crosswalk)
+ w.z = w.node.z + (w.axis === 'z' ? cross : w.edge * ROAD.crosswalk)
+ w.ry =
+ w.axis === 'x'
+ ? w.direction > 0
+ ? Math.PI / 2
+ : -Math.PI / 2
+ : w.direction > 0
+ ? 0
+ : Math.PI
+ }
+ lamp(node, axis) {
+ const phase = signalState(this.time)
+ if (node.pedestrians > 0) return 'red'
+ if (node.owner !== null) {
+ const owner = this.cars[node.owner]
+ if (owner.reservation?.axis !== axis) return 'red'
+ }
+ return phase[axis]
+ }
+ canWalk(node) {
+ return signalState(this.time).walk && node.owner === null
+ }
+ step(dt) {
+ let left = Math.min(Math.max(0, dt), 0.1)
+ while (left > 1e-8) {
+ const h = Math.min(left, 1 / 40)
+ this.tick(h)
+ left -= h
+ }
+ }
+ tick(dt) {
+ this.time += dt
+ const phase = signalState(this.time)
+ for (const w of this.walkers) {
+ if (w.kind === 'stroll') {
+ w.s += w.speed * dt
+ Object.assign(w, loopPoint(w.route, w.s))
+ continue
+ }
+ if (w.state === 'waiting') {
+ w.wait = Math.max(0, w.wait - dt)
+ const duration = (ROAD.sidewalk * 2) / w.speed
+ if (
+ w.wait === 0 &&
+ phase.walk &&
+ phase.remaining > duration + 0.3 &&
+ w.node.owner === null
+ ) {
+ w.state = 'crossing'
+ w.node.pedestrians++
+ }
+ }
+ if (w.state === 'crossing') {
+ w.progress = clamp(
+ w.progress + (w.direction * w.speed * dt) / (2 * ROAD.sidewalk),
+ 0,
+ 1,
+ )
+ if (w.progress === 0 || w.progress === 1) {
+ w.node.pedestrians--
+ w.state = 'waiting'
+ w.direction *= -1
+ w.wait = 8
+ }
+ }
+ this.positionCrossing(w)
+ }
+ for (const car of this.cars) {
+ const loop = car.route
+ if (car.reservation && car.s >= car.reservation.exitAt - 1e-7) {
+ car.reservation.node.owner = null
+ car.reservation = null
+ car.nextGate = (car.nextGate + 1) % 4
+ }
+ let gap = Infinity
+ for (const other of this.cars)
+ if (other !== car && other.route === loop)
+ gap = Math.min(gap, mod(other.s - car.s, loop.length))
+ let allowed = Math.max(0, gap - ROAD.minGap)
+ if (!car.reservation) {
+ const gate = loop.gates[car.nextGate]
+ let distance = mod(gate.entry - mod(car.s, loop.length), loop.length)
+ if (loop.length - distance < 1e-7) distance = 0
+ const permitted =
+ phase[gate.axis] === 'green' &&
+ gate.node.owner === null &&
+ gate.node.pedestrians === 0 &&
+ gap > 2 * ROAD.gateHalf + ROAD.minGap
+ if (permitted && distance < 0.45) {
+ gate.node.owner = car.id
+ car.reservation = {
+ node: gate.node,
+ axis: gate.axis,
+ exitAt: car.s + distance + 2 * ROAD.gateHalf,
+ }
+ } else allowed = Math.min(allowed, distance)
+ }
+ const target = Math.min(car.maxSpeed, Math.sqrt(2 * 2.4 * allowed))
+ car.speed =
+ target < car.speed
+ ? Math.max(target, car.speed - 2.4 * dt)
+ : Math.min(target, car.speed + 1.2 * dt)
+ const advance = Math.min(car.speed * dt, allowed)
+ car.s += advance
+ car.distance += advance
+ if (advance < car.speed * dt - 1e-8) car.speed = 0
+ Object.assign(car, loopPoint(loop, car.s))
+ }
+ }
+}
diff --git a/packages/react-charts/src/city/environment-controls.d.ts b/packages/react-charts/src/city/environment-controls.d.ts
new file mode 100644
index 00000000..0dc76e51
--- /dev/null
+++ b/packages/react-charts/src/city/environment-controls.d.ts
@@ -0,0 +1 @@
+export { EnvironmentControls } from './types'
diff --git a/packages/react-charts/src/city/environment-controls.jsx b/packages/react-charts/src/city/environment-controls.jsx
new file mode 100644
index 00000000..6c691ceb
--- /dev/null
+++ b/packages/react-charts/src/city/environment-controls.jsx
@@ -0,0 +1,66 @@
+import { useMapI18n } from './use-map-i18n.js'
+export function EnvironmentControls({
+ lighting,
+ weather,
+ onLightingChange,
+ onWeatherChange,
+ disabled,
+}) {
+ const { t } = useMapI18n()
+ return (
+ <>
+ {[
+ {
+ id: 'lighting',
+ ariaLabel: t('lightingLabel'),
+ label: t('lighting'),
+ value: lighting,
+ onChange: onLightingChange,
+ options: [
+ ['auto', t('auto')],
+ ['twilight', t('twilight')],
+ ['day', t('day')],
+ ['night', t('night')],
+ ],
+ },
+ {
+ id: 'weather',
+ ariaLabel: t('weatherLabel'),
+ label: t('weather'),
+ value: weather,
+ onChange: onWeatherChange,
+ options: [
+ ['auto', t('auto')],
+ ['none', t('none')],
+ ['rain', t('rain')],
+ ['snow', t('snow')],
+ ],
+ },
+ ].map((control) => (
+
+ {control.label}
+ control.onChange(event.target.value)}
+ disabled={disabled}
+ className="atlas-city-select"
+ >
+ {control.options.map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+ ))}
+ >
+ )
+}
diff --git a/packages/react-charts/src/city/fullscreen-wake-lock.test.mjs b/packages/react-charts/src/city/fullscreen-wake-lock.test.mjs
new file mode 100644
index 00000000..19d986d0
--- /dev/null
+++ b/packages/react-charts/src/city/fullscreen-wake-lock.test.mjs
@@ -0,0 +1,133 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import { maintainFullscreenWakeLock } from './engine/lib/fullscreen-wake-lock.js'
+
+const flush = () => new Promise((resolve) => setImmediate(resolve))
+function setup(request) {
+ const document = new EventTarget()
+ document.visibilityState = 'visible'
+ const element = {}
+ const locks = []
+ const statuses = []
+ let requests = 0
+ const navigator = {
+ wakeLock: {
+ request: async (type) => {
+ requests++
+ assert.equal(type, 'screen')
+ if (request) return request()
+ const lock = new EventTarget()
+ lock.released = false
+ lock.release = async () => {
+ lock.released = true
+ lock.dispatchEvent(new Event('release'))
+ }
+ locks.push(lock)
+ return lock
+ },
+ },
+ }
+ const dispose = maintainFullscreenWakeLock({
+ document,
+ navigator,
+ element: () => element,
+ onStatus: (status) => statuses.push(status),
+ })
+ return {
+ document,
+ navigator,
+ element,
+ locks,
+ statuses,
+ dispose,
+ get requests() {
+ return requests
+ },
+ async fullscreen(value) {
+ document.fullscreenElement = value
+ document.dispatchEvent(new Event('fullscreenchange'))
+ await flush()
+ },
+ async visible(value) {
+ document.visibilityState = value
+ document.dispatchEvent(new Event('visibilitychange'))
+ await flush()
+ },
+ }
+}
+
+test('wake lock belongs only to this fullscreen map, reacquires on visibility and releases on exit', async () => {
+ const state = setup()
+ assert.equal(state.requests, 0)
+ await state.fullscreen({})
+ assert.equal(state.requests, 0)
+ await state.fullscreen(state.element)
+ assert.equal(state.requests, 1)
+ assert.equal(state.statuses.at(-1), 'active')
+ await state.visible('visible')
+ assert.equal(state.requests, 1)
+ await state.visible('hidden')
+ assert.equal(state.locks[0].released, true)
+ await state.visible('visible')
+ assert.equal(state.requests, 2)
+ await state.fullscreen(null)
+ assert.equal(state.locks[1].released, true)
+ assert.equal(state.statuses.at(-1), 'idle')
+ state.dispose()
+})
+
+test('unmount releases the lock and removes event listeners', async () => {
+ const state = setup()
+ await state.fullscreen(state.element)
+ state.dispose()
+ assert.equal(state.locks[0].released, true)
+ await state.visible('visible')
+ assert.equal(state.requests, 1)
+})
+
+test('late requests cannot keep the screen awake after fullscreen exit or unmount', async () => {
+ for (const unmount of [false, true]) {
+ let resolve
+ const state = setup(
+ () =>
+ new Promise((done) => {
+ resolve = done
+ }),
+ )
+ await state.fullscreen(state.element)
+ if (unmount) state.dispose()
+ else await state.fullscreen(null)
+ let released = false
+ resolve({
+ release: async () => {
+ released = true
+ },
+ })
+ await flush()
+ assert.equal(released, true)
+ state.dispose()
+ }
+})
+
+test('unsupported or rejected wake locks report unavailable without breaking fullscreen', async () => {
+ for (const unsupported of [false, true]) {
+ const state = setup(() => Promise.reject(new Error('NotAllowedError')))
+ if (unsupported) delete state.navigator.wakeLock
+ await state.fullscreen(state.element)
+ assert.equal(state.statuses.at(-1), 'unavailable')
+ assert.equal(state.document.fullscreenElement, state.element)
+ state.dispose()
+ }
+})
+
+test('system revocation reports unavailable and retries on the next visibility event', async () => {
+ const state = setup()
+ await state.fullscreen(state.element)
+ await state.locks[0].release()
+ assert.equal(state.statuses.at(-1), 'unavailable')
+ assert.equal(state.requests, 1)
+ await state.visible('visible')
+ assert.equal(state.requests, 2)
+ assert.equal(state.statuses.at(-1), 'active')
+ state.dispose()
+})
diff --git a/packages/react-charts/src/city/i18n.js b/packages/react-charts/src/city/i18n.js
new file mode 100644
index 00000000..fabd9bba
--- /dev/null
+++ b/packages/react-charts/src/city/i18n.js
@@ -0,0 +1,246 @@
+// Interface text only: company names, categories and metric labels belong to the data.
+export const mapMessages = {
+ 'pt-BR': {
+ title: 'Distrito das empresas',
+ mapLabel: 'Mapa interativo de empresas',
+ chartLabel: 'Health score das empresas em uma cidade 3D',
+ viewportLabel:
+ 'Cidade 3D. Arraste para girar, use a roda para zoom, clique em uma empresa para detalhes.',
+ chartDescription:
+ 'Cada construção representa uma empresa. Altura indica o valor da métrica, cor indica categorias e efeitos indicam o nível de risco.',
+ chartRole: 'gráfico 3D',
+ searchMap: 'Buscar empresa no mapa',
+ search: 'Buscar empresa',
+ searchPlaceholder: 'Buscar empresa…',
+ findPlaceholder: 'Encontrar uma empresa…',
+ empty: 'Nenhuma empresa encontrada.',
+ perspective: 'Perspectiva',
+ iso: 'Isométrica',
+ top: 'Vista superior',
+ details: 'Detalhes da empresa',
+ closeDetails: 'Fechar detalhes',
+ selectedCompany: 'EMPRESA SELECIONADA',
+ categories: 'Categorias',
+ focus: 'Focar empresa',
+ zoomCompany: 'Aproximar empresa',
+ cameraControls: 'Controles da câmera',
+ zoomIn: 'Aproximar',
+ zoomOut: 'Afastar',
+ resetView: 'Restaurar visão geral',
+ resetOrientation: 'Restaurar visão isométrica',
+ exitFullscreen: 'Sair da tela cheia',
+ enterFullscreen: 'Abrir mapa em tela cheia',
+ fullscreenUnavailable: 'Tela cheia indisponível neste navegador',
+ fullscreenError: 'Não foi possível alterar a tela cheia.',
+ fullscreenRetry: 'Não foi possível alterar a tela cheia. Tente novamente.',
+ hideControls: 'Ocultar controles',
+ showControls: 'Mostrar controles',
+ life: 'Cidade viva',
+ effects: 'Efeitos de risco',
+ autoRotate: 'Rotação automática',
+ localClock: 'Horário local',
+ monitoring: 'Acompanhamento da operação',
+ companySummary: 'Resumo das empresas filtradas',
+ companiesFound: 'Empresas encontradas',
+ allInCity: 'todas em uma cidade',
+ companiesPerCity: 'até {count} empresas por cidade',
+ recentCritical: 'Últimas entradas em alto risco',
+ monitorScope: 'Toda a base · até 4 empresas',
+ monitorLoading: 'Carregando empresas…',
+ monitorUnavailable: 'Não foi possível carregar as empresas.',
+ noCritical: 'Nenhuma empresa em alto risco.',
+ criticalWithoutDate: '{count} em alto risco sem data de entrada informada.',
+ updatedAt: 'Última atualização',
+ statusChangedAt: 'Saúde atual desde',
+ dateUnknown: 'Data não informada',
+ wakeLockUnavailable:
+ 'Não foi possível manter a tela ativa. Verifique as configurações de repouso do dispositivo.',
+ districtLabels: 'Categorias e interseções',
+ updating: 'Atualizando cidade…',
+ building: 'Construindo a cidade…',
+ buildingTitle: 'Construindo a cidade',
+ loadingCity: 'Carregando cidade {page}…',
+ preparing: 'Preparando empresas e bairros…',
+ loadError: 'Não foi possível carregar a aplicação.',
+ retry: 'Tentar novamente',
+ clearFilters: 'Limpar filtros',
+ allCompanies: 'Todas as empresas',
+ companyLimit: 'Até {count} empresas',
+ cityPage: 'cidade {page}',
+ companyRange: '{start}–{end} de {total} empresas',
+ instructions:
+ 'Arraste para girar · Scroll para zoom · Botão direito para mover',
+ lighting: 'Iluminação',
+ lightingLabel: 'Iluminação da cidade',
+ weather: 'Clima',
+ weatherLabel: 'Clima da cidade',
+ auto: 'Automático',
+ twilight: 'Nascente/poente',
+ day: 'Dia',
+ night: 'Noite',
+ none: 'Sem precipitação',
+ rain: 'Chuva',
+ snow: 'Neve',
+ 'simulation.none': 'Simulação: sem precipitação',
+ 'simulation.rain': 'Simulação: chuva',
+ 'simulation.snow': 'Simulação: neve',
+ 'status.healthy': 'Saudável',
+ 'status.warning': 'Risco médio',
+ 'status.critical': 'Alto risco',
+ 'description.healthy':
+ 'Operação saudável. A construção mantém sua aparência normal.',
+ 'description.warning':
+ 'Ponto de atenção: fumaça clara e alerta amarelo sinalizam risco médio.',
+ 'description.critical':
+ 'Alto risco: construção piscando em vermelho, chamas nas janelas e fumaça escura indicam prioridade de atenção.',
+ weatherPaused: 'Clima pausado',
+ 'weather.rain': 'Chuva na região',
+ 'weather.snow': 'Neve na região',
+ 'weather.none': 'Sem chuva ou neve na região',
+ locationDenied: 'Localização não autorizada · clima indisponível',
+ weatherUnavailable: 'Clima indisponível · nova tentativa em 15 min',
+ locationPrompt: 'Permita a localização para mostrar chuva ou neve',
+ locationUnavailable: 'Localização indisponível neste navegador',
+ locationError: 'Não foi possível obter a localização',
+ weatherLoading: 'Consultando o clima da região…',
+ contextLost:
+ 'A conexão gráfica foi interrompida. Recarregue a página para restaurar o mapa.',
+ webglError:
+ 'Não foi possível abrir a cidade 3D. Use um navegador com WebGL habilitado e aceleração gráfica disponível.',
+ },
+ 'en-US': {
+ title: 'Business district',
+ mapLabel: 'Interactive company map',
+ chartLabel: 'Company health scores in a 3D city',
+ viewportLabel:
+ '3D city. Drag to rotate, scroll to zoom, click a company for details.',
+ chartDescription:
+ 'Each building represents a company. Height indicates the metric value, color indicates categories, and effects indicate the risk level.',
+ chartRole: '3D chart',
+ searchMap: 'Search companies on the map',
+ search: 'Search companies',
+ searchPlaceholder: 'Search companies…',
+ findPlaceholder: 'Find a company…',
+ empty: 'No companies found.',
+ perspective: 'Perspective',
+ iso: 'Isometric',
+ top: 'Top view',
+ details: 'Company details',
+ closeDetails: 'Close details',
+ selectedCompany: 'SELECTED COMPANY',
+ categories: 'Categories',
+ focus: 'Focus company',
+ zoomCompany: 'Zoom to company',
+ cameraControls: 'Camera controls',
+ zoomIn: 'Zoom in',
+ zoomOut: 'Zoom out',
+ resetView: 'Reset view',
+ resetOrientation: 'Restore isometric view',
+ exitFullscreen: 'Exit fullscreen',
+ enterFullscreen: 'Open map in fullscreen',
+ fullscreenUnavailable: 'Fullscreen is unavailable in this browser',
+ fullscreenError: 'Could not change fullscreen mode.',
+ fullscreenRetry: 'Could not change fullscreen mode. Try again.',
+ hideControls: 'Hide controls',
+ showControls: 'Show controls',
+ life: 'City life',
+ effects: 'Risk effects',
+ autoRotate: 'Auto rotation',
+ localClock: 'Local time',
+ monitoring: 'Operations monitoring',
+ companySummary: 'Company summary',
+ companiesFound: 'Companies found',
+ allInCity: 'all in one city',
+ companiesPerCity: 'up to {count} companies per city',
+ recentCritical: 'Latest high-risk entries',
+ monitorScope: 'All companies · up to 4 entries',
+ monitorLoading: 'Loading companies…',
+ monitorUnavailable: 'Could not load companies.',
+ noCritical: 'No high-risk companies.',
+ criticalWithoutDate: '{count} high-risk companies have no entry date.',
+ updatedAt: 'Last updated',
+ statusChangedAt: 'Current health since',
+ dateUnknown: 'Date not provided',
+ wakeLockUnavailable:
+ "Could not keep the screen awake. Check your device's sleep settings.",
+ districtLabels: 'Categories and intersections',
+ updating: 'Updating city…',
+ building: 'Building the city…',
+ buildingTitle: 'Building the city',
+ loadingCity: 'Loading city {page}…',
+ preparing: 'Preparing companies and districts…',
+ loadError: 'Could not load the application.',
+ retry: 'Try again',
+ clearFilters: 'Clear filters',
+ allCompanies: 'All companies',
+ companyLimit: 'Up to {count} companies',
+ cityPage: 'city {page}',
+ companyRange: '{start}–{end} of {total} companies',
+ instructions: 'Drag to rotate · Scroll to zoom · Right mouse button to pan',
+ lighting: 'Lighting',
+ lightingLabel: 'City lighting',
+ weather: 'Weather',
+ weatherLabel: 'City weather',
+ auto: 'Automatic',
+ twilight: 'Sunrise/sunset',
+ day: 'Day',
+ night: 'Night',
+ none: 'No precipitation',
+ rain: 'Rain',
+ snow: 'Snow',
+ 'simulation.none': 'Simulation: no precipitation',
+ 'simulation.rain': 'Simulation: rain',
+ 'simulation.snow': 'Simulation: snow',
+ 'status.healthy': 'Healthy',
+ 'status.warning': 'Medium risk',
+ 'status.critical': 'High risk',
+ 'description.healthy':
+ 'Healthy operation. The building retains its normal appearance.',
+ 'description.warning':
+ 'Attention needed: light smoke and a yellow warning indicate medium risk.',
+ 'description.critical':
+ 'High risk: a building flashing red, flames in the windows, and dark smoke indicate a need for urgent attention.',
+ weatherPaused: 'Weather paused',
+ 'weather.rain': 'Rain in the area',
+ 'weather.snow': 'Snow in the area',
+ 'weather.none': 'No rain or snow in the area',
+ locationDenied: 'Location permission denied · weather unavailable',
+ weatherUnavailable: 'Weather unavailable · retrying in 15 min',
+ locationPrompt: 'Allow location access to show rain or snow',
+ locationUnavailable: 'Location is unavailable in this browser',
+ locationError: 'Could not determine your location',
+ weatherLoading: 'Checking local weather…',
+ contextLost:
+ 'The graphics connection was lost. Reload the page to restore the map.',
+ webglError:
+ 'Could not open the 3D city. Use a browser with WebGL enabled and graphics acceleration available.',
+ },
+}
+
+export function resolveMapLocale(language) {
+ return typeof language === 'string' && language.toLowerCase() === 'pt-br'
+ ? 'pt-BR'
+ : 'en-US'
+}
+
+export function getBrowserMapLocale() {
+ return resolveMapLocale(
+ typeof window === 'undefined' ? undefined : window.navigator.language,
+ )
+}
+
+export function getMapI18n(language) {
+ const locale = resolveMapLocale(language)
+ const messages = mapMessages[locale]
+ const formatter = new Intl.NumberFormat(locale)
+ return {
+ locale,
+ format: (value) => formatter.format(value),
+ t: (key, values = {}) =>
+ (messages[key] ?? mapMessages['en-US'][key] ?? key).replace(
+ /\{(\w+)\}/g,
+ (match, name) =>
+ Object.hasOwn(values, name) ? String(values[name]) : match,
+ ),
+ }
+}
diff --git a/packages/react-charts/src/city/index.d.ts b/packages/react-charts/src/city/index.d.ts
new file mode 100644
index 00000000..379836ab
--- /dev/null
+++ b/packages/react-charts/src/city/index.d.ts
@@ -0,0 +1,6 @@
+export {
+ defineCityChart,
+ cityMark,
+ cityFocus,
+ createCityRenderer,
+} from './types'
diff --git a/packages/react-charts/src/city/index.js b/packages/react-charts/src/city/index.js
new file mode 100644
index 00000000..69e1ca10
--- /dev/null
+++ b/packages/react-charts/src/city/index.js
@@ -0,0 +1,4 @@
+export { cityMark } from './mark.js'
+export { defineCityChart } from './definition.js'
+export { createCityRenderer } from './renderer.js'
+export { cityFocus } from './interaction.js'
diff --git a/packages/react-charts/src/city/interaction.js b/packages/react-charts/src/city/interaction.js
new file mode 100644
index 00000000..5e3a1089
--- /dev/null
+++ b/packages/react-charts/src/city/interaction.js
@@ -0,0 +1,19 @@
+// A definition can be mounted in several charts. Associate each compiled point
+// with its own mounted surface, never with a global camera or a datum ID.
+const surfaces = new WeakMap()
+
+export function bindCityPoints(points, resolve) {
+ for (const point of points) surfaces.set(point, resolve)
+}
+
+export const cityFocus = {
+ resolve(points, { x, y }) {
+ return surfaces.get(points[0])?.(x, y) ?? []
+ },
+ group(_points, { point }) {
+ return [point]
+ },
+ navigation(points) {
+ return points
+ },
+}
diff --git a/packages/react-charts/src/city/landscape.test.mjs b/packages/react-charts/src/city/landscape.test.mjs
new file mode 100644
index 00000000..193f7c07
--- /dev/null
+++ b/packages/react-charts/src/city/landscape.test.mjs
@@ -0,0 +1,96 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import { greenLots } from './engine/landscape.js'
+import {
+ demoCompanies,
+ layoutCity,
+ paginateCompanies,
+ PAGE_SIZES,
+ ROAD_EVERY,
+} from './engine/model.js'
+
+test('every area without a company receives a garden without occupying streets for every filter and page', () => {
+ const records = demoCompanies()
+ for (const size of PAGE_SIZES)
+ for (const filter of [
+ {},
+ { region: '7' },
+ { status: 'warning' },
+ { query: 'sem-resultados-xyz' },
+ ]) {
+ const first = paginateCompanies(records, filter, 1, size)
+ for (const page of new Set([1, first.pages])) {
+ const layout = layoutCity(
+ paginateCompanies(records, filter, page, size).companies,
+ )
+ const lots = greenLots(layout),
+ green = new Set(lots.map((p) => `${p.col},${p.row}`))
+ const occupied = new Set(
+ layout.companies.map((c) => `${c.col},${c.row}`),
+ )
+ assert.equal(green.size, lots.length)
+ const b = layout.roadBounds
+ for (let row = b.z1; row <= b.z2; row++)
+ for (let col = b.x1; col <= b.x2; col++) {
+ const key = `${col},${row}`,
+ road = col % ROAD_EVERY === 0 || row % ROAD_EVERY === 0
+ assert.equal(green.has(key), !road && !occupied.has(key))
+ }
+ for (const lot of lots) {
+ assert.ok(lot.trees.length <= 3)
+ for (const tree of lot.trees) {
+ // Even the full canopy stays inside its lot and off sidewalks.
+ assert.ok(Math.abs(tree.dx) + tree.scale < (2.8 - 0.12) / 2)
+ assert.ok(Math.abs(tree.dz) + tree.scale < (2.8 - 0.12) / 2)
+ for (const bench of lot.benches)
+ assert.ok(
+ Math.hypot(tree.dx - bench.dx, tree.dz - bench.dz) >=
+ tree.scale + 0.55,
+ )
+ }
+ for (const bench of lot.benches) {
+ // The entire rotated bench fits inside the lawn lot.
+ const cos = Math.abs(Math.cos(bench.rotation)),
+ sin = Math.abs(Math.sin(bench.rotation))
+ assert.ok(Math.abs(bench.dx) + 0.24 * cos + 0.08 * sin < 1.34)
+ assert.ok(Math.abs(bench.dz) + 0.24 * sin + 0.08 * cos < 1.34)
+ }
+ }
+ }
+ }
+})
+
+test('gardens vary between clearings and tree clusters with a few stable benches', () => {
+ const layout = layoutCity(demoCompanies().slice(0, 100))
+ const lots = greenLots(layout)
+ assert.deepEqual(greenLots(layout), lots)
+ assert.deepEqual(
+ [...new Set(lots.map((lot) => lot.trees.length))].sort(),
+ [0, 1, 2, 3],
+ )
+ const benches = lots.filter((lot) => lot.benches.length > 0)
+ assert.ok(benches.length > 0 && benches.length < lots.length * 0.15)
+ assert.ok(new Set(benches.map((lot) => lot.benches[0].rotation)).size > 1)
+ for (const lot of lots)
+ for (let i = 0; i < lot.trees.length; i++)
+ for (const other of lot.trees.slice(i + 1)) {
+ const tree = lot.trees[i]
+ assert.ok(
+ Math.hypot(tree.dx - other.dx, tree.dz - other.dz) >=
+ tree.scale + other.scale + 0.08,
+ )
+ }
+})
+
+test('a freed lot becomes a garden and can receive a company again without overlapping vegetation', () => {
+ const layout = layoutCity(demoCompanies().slice(0, 100)),
+ removed = layout.companies[0]
+ const before = greenLots(layout)
+ const vacant = greenLots({ ...layout, companies: layout.companies.slice(1) })
+ assert.ok(vacant.some((p) => p.col === removed.col && p.row === removed.row))
+ assert.deepEqual(
+ vacant.filter((p) => p.col !== removed.col || p.row !== removed.row),
+ before,
+ )
+ assert.deepEqual(greenLots(layout), before)
+})
diff --git a/packages/react-charts/src/city/layout.js b/packages/react-charts/src/city/layout.js
new file mode 100644
index 00000000..9d1e0653
--- /dev/null
+++ b/packages/react-charts/src/city/layout.js
@@ -0,0 +1,13 @@
+// Renderer metadata lives on the mark's scene group, never on user data.
+// A symbol survives scene-node copies without becoming an SVG/DOM attribute.
+export const cityLayout = Symbol('cityLayout')
+
+export function readCityLayout(nodes) {
+ for (const node of nodes) {
+ if (node[cityLayout]) return node[cityLayout]
+ if (node.children) {
+ const layout = readCityLayout(node.children)
+ if (layout) return layout
+ }
+ }
+}
diff --git a/packages/react-charts/src/city/map-compass.test.mjs b/packages/react-charts/src/city/map-compass.test.mjs
new file mode 100644
index 00000000..42077209
--- /dev/null
+++ b/packages/react-charts/src/city/map-compass.test.mjs
@@ -0,0 +1,42 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { mapNorthAngle } from './engine/lib/map-compass.js'
+
+test('north follows the projected ground direction at every camera heading and elevation', () => {
+ const camera = new THREE.OrthographicCamera(-160, 160, 100, -100, 0.1, 800)
+ for (const polar of [1e-6, Math.acos(1 / Math.sqrt(3))]) {
+ for (const azimuth of [
+ 0,
+ Math.PI / 4,
+ Math.PI / 2,
+ Math.PI,
+ -Math.PI / 2,
+ ]) {
+ for (const zoom of [0.5, 1, 3]) {
+ const target = new THREE.Vector3(20, 5, -10)
+ camera.position
+ .copy(target)
+ .add(new THREE.Vector3().setFromSphericalCoords(200, polar, azimuth))
+ camera.lookAt(target)
+ camera.zoom = zoom
+ camera.updateProjectionMatrix()
+ camera.updateMatrixWorld()
+ const origin = target.clone().project(camera)
+ const north = target
+ .clone()
+ .add(new THREE.Vector3(0, 0, -1))
+ .project(camera)
+ // Convert NDC to pixels for a viewport matching the camera's aspect.
+ const expected = Math.atan2(
+ (north.x - origin.x) * 160,
+ (north.y - origin.y) * 100,
+ )
+ const actual = mapNorthAngle(camera)
+ assert.ok(Math.abs(Math.sin(actual) - Math.sin(expected)) < 1e-9)
+ assert.ok(Math.abs(Math.cos(actual) - Math.cos(expected)) < 1e-9)
+ if (azimuth === 0) assert.ok(Math.abs(actual) < 1e-9)
+ }
+ }
+ }
+})
diff --git a/packages/react-charts/src/city/map-tooltip.test.mjs b/packages/react-charts/src/city/map-tooltip.test.mjs
new file mode 100644
index 00000000..fc361a3e
--- /dev/null
+++ b/packages/react-charts/src/city/map-tooltip.test.mjs
@@ -0,0 +1,30 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import { mapTooltipAnchor } from './engine/lib/map-tooltip.js'
+
+test('the anchor keeps the roof projection inside the map', () => {
+ assert.deepEqual(mapTooltipAnchor({ x: 0.5, y: -0.5 }, 800, 600), {
+ x: 600,
+ y: 450,
+ })
+})
+
+test('roofs outside the view retain a visible anchor at the edges', () => {
+ for (const point of [
+ { x: 0, y: 2 },
+ { x: 0, y: -2 },
+ { x: -2, y: 0 },
+ { x: 2, y: 0 },
+ { x: 4, y: 4, z: 2 },
+ ]) {
+ const anchor = mapTooltipAnchor(point, 800, 600)
+ assert.ok(anchor.x >= 18 && anchor.x <= 782)
+ assert.ok(anchor.y >= 18 && anchor.y <= 582)
+ }
+})
+
+test('transient dimensions do not produce invalid anchors', () => {
+ assert.equal(mapTooltipAnchor({ x: 0, y: 0 }, 0, 600), null)
+ assert.equal(mapTooltipAnchor({ x: NaN, y: 0 }, 800, 600), null)
+ assert.deepEqual(mapTooltipAnchor({ x: 2, y: 2 }, 20, 10), { x: 10, y: 5 })
+})
diff --git a/packages/react-charts/src/city/map.d.ts b/packages/react-charts/src/city/map.d.ts
new file mode 100644
index 00000000..2afce27c
--- /dev/null
+++ b/packages/react-charts/src/city/map.d.ts
@@ -0,0 +1 @@
+export { CityMap } from './types'
diff --git a/packages/react-charts/src/city/map.jsx b/packages/react-charts/src/city/map.jsx
new file mode 100644
index 00000000..06a0c34b
--- /dev/null
+++ b/packages/react-charts/src/city/map.jsx
@@ -0,0 +1,502 @@
+import { useMapI18n } from './use-map-i18n.js'
+import {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { defineCityChart } from './definition.js'
+import { CityChart } from './react.jsx'
+import { CompanyDate, CompanyMonitor } from './company-monitor.jsx'
+import { useFullscreenWakeLock } from './use-fullscreen-wake-lock.js'
+import { categoryLabel, metricFor } from './engine/model.js'
+
+function useSetting(value, initial, onChange) {
+ const [local, setLocal] = useState(initial)
+ return [
+ value ?? local,
+ (next) => {
+ setLocal(next)
+ onChange?.(next)
+ },
+ ]
+}
+
+/** A complete, self-contained city with camera, weather and scene controls. */
+export const CityMap = forwardRef(function CityMap(
+ {
+ data,
+ onSelect,
+ onFocusChange,
+ view: viewProp,
+ onViewChange,
+ life: lifeProp,
+ onLifeChange,
+ effects: effectsProp,
+ onEffectsChange,
+ autoRotate: autoRotateProp,
+ onAutoRotateChange,
+ showDistrictLabels: labelsProp,
+ onDistrictLabelsChange,
+ weather = 'none',
+ lightingMode = 'auto',
+ showControls = true,
+ showEnvironmentControls = false,
+ showSearch = true,
+ showDetails = true,
+ showMonitor = true,
+ showCompass = true,
+ showInstructions = true,
+ busy = false,
+ title: titleProp,
+ ariaLabel: ariaLabelProp,
+ className = '',
+ theme = 'default',
+ children,
+ },
+ ref,
+) {
+ const { t, format, locale } = useMapI18n()
+ const title = titleProp ?? t('title')
+ const ariaLabel = ariaLabelProp ?? t('mapLabel')
+ const panelRef = useRef(null)
+ const chartRef = useRef(null)
+ const [view, setView] = useSetting(viewProp, 'iso', onViewChange)
+ const [life, setLife] = useSetting(lifeProp, true, onLifeChange)
+ const [effects, setEffects] = useSetting(effectsProp, true, onEffectsChange)
+ const [autoRotate, setAutoRotate] = useSetting(
+ autoRotateProp,
+ false,
+ onAutoRotateChange,
+ )
+ const [labels, setLabels] = useSetting(
+ labelsProp,
+ false,
+ onDistrictLabelsChange,
+ )
+ const [selectedId, setSelectedId] = useState(null)
+ const [query, setQuery] = useState('')
+ const [controlsVisible, setControlsVisible] = useState(true)
+ const [fullscreen, setFullscreen] = useState(false)
+ const [fullscreenAvailable, setFullscreenAvailable] = useState(false)
+ const [fullscreenError, setFullscreenError] = useState(null)
+ const wakeLockStatus = useFullscreenWakeLock(panelRef)
+ const definition = useMemo(() => defineCityChart(data), [data])
+ const selected = data.find((datum) => datum.id === selectedId) ?? null
+ const matches = query.trim()
+ ? data
+ .filter((datum) =>
+ datum.name
+ .toLocaleLowerCase()
+ .includes(query.trim().toLocaleLowerCase()),
+ )
+ .slice(0, 8)
+ : []
+ const choose = (datum, focus = false, maxZoom = false) => {
+ setSelectedId(datum?.id ?? null)
+ onSelect?.(datum)
+ if (datum && focus)
+ chartRef.current?.select(datum, { focus: true, maxZoom })
+ }
+ const changeView = (next) => {
+ setView(next)
+ chartRef.current?.view(next)
+ }
+
+ useImperativeHandle(ref, () => ({
+ zoom: (factor) => chartRef.current?.zoom(factor),
+ view: changeView,
+ select: (datum, options = {}) =>
+ choose(datum, options.focus, options.maxZoom),
+ deselect: () => choose(null),
+ cancel: () => chartRef.current?.cancel(),
+ capture: () => chartRef.current?.capture(),
+ play: (direction) => chartRef.current?.play(direction),
+ scrollIntoView: () => chartRef.current?.scrollIntoView(),
+ stats: () => chartRef.current?.stats(),
+ }))
+
+ useEffect(() => {
+ const update = () =>
+ setFullscreen(document.fullscreenElement === panelRef.current)
+ setFullscreenAvailable(
+ Boolean(
+ document.fullscreenEnabled && panelRef.current?.requestFullscreen,
+ ),
+ )
+ document.addEventListener('fullscreenchange', update)
+ return () => document.removeEventListener('fullscreenchange', update)
+ }, [])
+
+ const toggleFullscreen = async () => {
+ setFullscreenError(null)
+ try {
+ chartRef.current?.cancel()
+ if (document.fullscreenElement === panelRef.current)
+ await document.exitFullscreen()
+ else await panelRef.current.requestFullscreen()
+ } catch {
+ setFullscreenError('fullscreenError')
+ }
+ }
+
+ return (
+ {
+ if (
+ event.target.closest(
+ "input, textarea, select, button, [contenteditable='true']",
+ )
+ )
+ return
+ if (event.key === 'Escape') choose(null)
+ if (busy || !['+', '=', '-', '0'].includes(event.key)) return
+ event.preventDefault()
+ if (event.key === '0') changeView('iso')
+ else chartRef.current?.zoom(event.key === '-' ? 1 / 1.3 : 1.3)
+ }}
+ >
+ choose(point?.datum ?? null)}
+ onFocusChange={onFocusChange}
+ view={view}
+ life={life}
+ effects={effects}
+ autoRotate={autoRotate}
+ weather={weather}
+ lightingMode={lightingMode}
+ showDistrictLabels={labels}
+ showEnvironmentControls={showControls && showEnvironmentControls}
+ selected={selected}
+ busy={busy}
+ ariaLabel={ariaLabel}
+ >
+ {({ ready, error, environmentControls }) => {
+ const disabled = busy || !ready || Boolean(error)
+ return (
+ <>
+
+
+
+ {showControls && (
+
+
+ {t('perspective')}
+
+ {[
+ ['iso', t('iso')],
+ ['top', t('top')],
+ ].map(([value, label]) => (
+ changeView(value)}
+ >
+ {label}
+
+ ))}
+
+ )}
+ {fullscreenError && (
+
+ {t(fullscreenError)}
+
+ )}
+ {wakeLockStatus === 'unavailable' && (
+
+ {t('wakeLockUnavailable')}
+
+ )}
+
+
+ {showMonitor && (
+
+ )}
+
+
+ {showCompass && (
+
changeView('iso')}
+ >
+ N
+
+ ↑
+
+
+ )}
+
+ {showDetails && selected && (
+
+ choose(null)}
+ >
+ ×
+
+
+ {selected.name}
+
+
+ {categoryLabel(selected)}
+
+
+ {metricFor(selected).label}:{' '}
+ {format(metricFor(selected).value)}
+
+
+ {t(`status.${selected.status}`)}
+
+
+ {t(`description.${selected.status}`)}
+
+
+
+
{t('updatedAt')}
+
+
+
+
+
+
{t('statusChangedAt')}
+
+
+
+
+
+
+ chartRef.current?.select(selected, {
+ focus: true,
+ maxZoom: true,
+ })
+ }
+ >
+ {t('focus')}
+
+
+ )}
+ {!disabled && data.length === 0 && (
+
+ {t('empty')}
+
+ )}
+
+ {showControls && (
+
{
+ if (node) node.inert = !controlsVisible
+ }}
+ data-controls-visible={controlsVisible}
+ >
+ {environmentControls}
+ {[
+ ['life', t('life'), life, setLife],
+ ['effects', t('effects'), effects, setEffects],
+ [
+ 'auto-rotate',
+ t('autoRotate'),
+ autoRotate,
+ setAutoRotate,
+ ],
+ ['labels', t('districtLabels'), labels, setLabels],
+ ].map(([id, label, checked, change]) => (
+
+ change(event.target.checked)}
+ />
+
+ {label}
+
+
+ ))}
+
+ )}
+
+
+ {showInstructions && (
+
+ {t('instructions')}
+
+ {t('companyRange', {
+ start: format(data.length ? 1 : 0),
+ end: format(data.length),
+ total: format(data.length),
+ })}
+
+
+ )}
+ {showControls && (
+ <>
+
+ {[
+ [t('zoomIn'), '+', () => chartRef.current?.zoom(1.3)],
+ [
+ t('zoomOut'),
+ '−',
+ () => chartRef.current?.zoom(1 / 1.3),
+ ],
+ [t('resetView'), '⌂', () => changeView(view)],
+ [
+ fullscreen ? t('exitFullscreen') : t('enterFullscreen'),
+ fullscreen ? '⤡' : '⤢',
+ toggleFullscreen,
+ ],
+ ].map(([label, icon, action], index) => (
+
+ {icon}
+
+ ))}
+
setControlsVisible((visible) => !visible)}
+ >
+
+
+
+ {controlsVisible && }
+
+
+
+ >
+ )}
+ {disabled && (
+
+ {error || (busy ? t('updating') : t('building'))}
+
+ )}
+ {children}
+ >
+ )
+ }}
+
+
+ )
+})
diff --git a/packages/react-charts/src/city/mark.js b/packages/react-charts/src/city/mark.js
new file mode 100644
index 00000000..e911e719
--- /dev/null
+++ b/packages/react-charts/src/city/mark.js
@@ -0,0 +1,65 @@
+import { createMark } from '@tanstack/charts'
+import { categoryLabel, metricFor, layoutCity } from './engine/model.js'
+import { validateCityData } from './data.js'
+import { cityLayout } from './layout.js'
+
+// A city is a spatial mark: x/z locate each company and a metric encodes height.
+// The semantic scene also remains usable by TanStack's SVG renderer (top view).
+/**
+ * @template {import('./data.js').CityDatum} TDatum
+ * @param {readonly TDatum[]} data Original application rows, never mutated.
+ * @param {{ id?: string, layout?: ReturnType }} [options]
+ * @returns {import('@tanstack/charts').ChartMark}
+ */
+export function cityMark(data, { id = 'companies', layout } = {}) {
+ const rows = validateCityData(data)
+ layout ??= layoutCity(rows)
+ return createMark(() => ({
+ id,
+ channels: {
+ x: { scale: 'x', values: layout.companies.map((c) => c.x) },
+ y: { scale: 'y', values: layout.companies.map((c) => c.z) },
+ color: { scale: 'color', values: layout.companies.map((c) => c.mask) },
+ height: { values: layout.companies.map((c) => metricFor(c).value) },
+ health: { values: layout.companies.map((c) => c.status) },
+ },
+ render({ scales, color }) {
+ const points = layout.companies.map((company, datumIndex) => ({
+ key: `${id}:${company.id}`,
+ markId: id,
+ group: company.mask,
+ groupLabel: categoryLabel(company),
+ datum: data[datumIndex],
+ datumIndex,
+ xValue: company.x,
+ yValue: company.z,
+ x: scales.x.map(company.x),
+ y: scales.y.map(company.z),
+ color: color(company.mask),
+ }))
+ return {
+ points,
+ nodes: [
+ {
+ kind: 'group',
+ className: 'ts-chart__rect',
+ ariaHidden: true,
+ key: `${id}:layout`,
+ [cityLayout]: layout,
+ children: points.map((point) => ({
+ kind: 'rect',
+ key: point.key,
+ x: point.x - 3,
+ y: point.y - 3,
+ width: 6,
+ height: 6,
+ radius: 1,
+ style: { fill: point.color },
+ interaction: { point, affinity: 'geometry' },
+ })),
+ },
+ ],
+ }
+ },
+ }))
+}
diff --git a/packages/react-charts/src/city/metric-scale.test.mjs b/packages/react-charts/src/city/metric-scale.test.mjs
new file mode 100644
index 00000000..92e7ce1d
--- /dev/null
+++ b/packages/react-charts/src/city/metric-scale.test.mjs
@@ -0,0 +1,109 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { metricDomainFor, sizeForValue, layoutCity } from './engine/model.js'
+import { facadeWindows } from './engine/plumes.js'
+import { City } from './engine/city.js'
+import { disposeSceneGroups } from './engine/scene-resources.js'
+
+const records = (values) =>
+ values.map((value, i) => ({
+ id: String(i),
+ name: `Company ${i}`,
+ mask: 1,
+ status: 'healthy',
+ metric: { label: 'Revenue', value },
+ }))
+
+test('adaptive heights distinguish values inside former bands at any magnitude', () => {
+ for (const values of [
+ [0, 0.25, 0.5],
+ [2000, 3000, 4000],
+ [2e9, 3e9, 4e9],
+ ]) {
+ const domain = metricDomainFor(records(values))
+ const sizes = values.map((value) => sizeForValue(value, domain))
+ assert.equal(sizes[0].height, 0.95)
+ assert.equal(sizes[2].height, 16.8)
+ assert.equal(sizes[2].floors, 14)
+ assert.equal(sizes[0].floors, 1)
+ assert.equal(sizes[1].height, 0.95 + Math.sqrt(0.5) * 15.85)
+ assert.ok(sizes[1].height > (0.95 + 16.8) / 2)
+ assert.ok(sizes[0].height < sizes[1].height)
+ assert.ok(sizes[1].height < sizes[2].height)
+ }
+})
+
+test('empty, single, equal, zero, duplicate and legacy metrics have defined heights', () => {
+ assert.deepEqual(metricDomainFor([]), [0, 0])
+ assert.deepEqual(metricDomainFor([{ users: 2 }, { users: 9 }]), [2, 9])
+ for (const values of [[0], [25], [0, 0], [100, 100]]) {
+ const domain = metricDomainFor(records(values))
+ for (const value of values)
+ assert.equal(sizeForValue(value, domain).height, 8.875)
+ }
+ const domain = metricDomainFor(records([1, 2, 2, 3]))
+ assert.deepEqual(domain, [1, 3])
+ assert.equal(sizeForValue(2, domain).height, 0.95 + Math.sqrt(0.5) * 15.85)
+ assert.equal(sizeForValue(-1, domain).height, 0.95)
+ assert.equal(sizeForValue(10, domain).height, 16.8)
+})
+
+test('square-root scaling gives intermediate values more presence and fits every facade', () => {
+ const domain = [0, 100]
+ assert.equal(sizeForValue(25, domain).height, 8.875)
+ let previous = 0
+ for (let value = 0; value <= 100; value += 0.25) {
+ const size = sizeForValue(value, domain)
+ assert.ok(size.height > previous)
+ assert.ok(size.floors >= 1 && size.floors <= 14)
+ previous = size.height
+ const company = { x: 0, z: 0, height: size.height }
+ const windows = facadeWindows(company, size, 1.5, 1.5)
+ assert.equal(windows.length, size.floors * 4)
+ for (const window of windows) {
+ assert.ok(window.y - window.height / 2 > 0)
+ assert.ok(window.y + window.height / 2 < size.height + 0.13)
+ }
+ }
+})
+
+test('rendered geometry rescales unchanged companies when the domain changes', () => {
+ const city = Object.assign(Object.create(City.prototype), {
+ scene: new THREE.Scene(),
+ terrain: new THREE.Group(),
+ buildings: new THREE.Group(),
+ palettes: new Map([[1, new THREE.Color('#4b91ef')]]),
+ renderer: { renderLists: { dispose() {} } },
+ selection: { visible: false },
+ resize() {},
+ buildTerrain() {},
+ buildTraffic() {},
+ buildEffects() {},
+ invalidate() {},
+ select() {},
+ })
+ try {
+ let layout = layoutCity(records([2000, 3000, 4000]))
+ city.setCompanies(layout.companies, layout)
+ const original = city.companies[1].height
+ assert.equal(city.body.userData.items[1].h, original)
+ layout = layoutCity(records([2000, 3000, 6000]))
+ city.updateCompanies(layout, new Map())
+ assert.ok(city.companies[1].height < original)
+ assert.equal(city.body.userData.items[1].h, city.companies[1].height)
+ // A full-data domain can change even when all companies on this page stay the same.
+ layout = {
+ ...layoutCity(records([2000, 3000, 6000])),
+ metricDomain: [0, 10000],
+ }
+ city.updateCompanies(layout, new Map())
+ assert.equal(
+ city.companies[1].height,
+ sizeForValue(3000, [0, 10000]).height,
+ )
+ assert.equal(city.body.userData.items[1].h, city.companies[1].height)
+ } finally {
+ disposeSceneGroups([city.scene, city.buildings])
+ }
+})
diff --git a/packages/react-charts/src/city/plume-renderer.test.mjs b/packages/react-charts/src/city/plume-renderer.test.mjs
new file mode 100644
index 00000000..dd0b8584
--- /dev/null
+++ b/packages/react-charts/src/city/plume-renderer.test.mjs
@@ -0,0 +1,38 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { WindowPlumes } from './engine/plume-renderer.js'
+import { facadeWindows, windowEmitters } from './engine/plumes.js'
+import { sizeForUsers } from './engine/model.js'
+
+test('Three.js volumes remain valid, respect selection and can be hidden', () => {
+ const c = {
+ id: 'company',
+ x: 3,
+ z: 6,
+ height: 4.3,
+ house: false,
+ status: 'critical',
+ },
+ size = sizeForUsers(1000)
+ const emitters = windowEmitters(c, facadeWindows(c, size, 1.5, 1.4)),
+ parent = new THREE.Group(),
+ plumes = new WindowPlumes(emitters, parent)
+ for (const time of [0, 0.5, 2, 1000]) {
+ plumes.update(time, true, new Set([c.id]))
+ for (const { mesh } of plumes.layers) {
+ assert.ok(mesh.isInstancedMesh)
+ assert.ok(mesh.count > 0)
+ assert.ok([...mesh.instanceMatrix.array].every(Number.isFinite))
+ assert.ok([...mesh.geometry.attributes.aAlpha.array].some((a) => a > 0))
+ }
+ }
+ plumes.update(1, true, new Set())
+ for (const { mesh } of plumes.layers)
+ assert.ok([...mesh.geometry.attributes.aAlpha.array].every((a) => a === 0))
+ plumes.update(1, false, new Set([c.id]))
+ for (const { mesh } of plumes.layers) assert.equal(mesh.visible, false)
+ const empty = new WindowPlumes([], new THREE.Group())
+ empty.update(1, true, new Set())
+ for (const { mesh } of empty.layers) assert.equal(mesh.count, 0)
+})
diff --git a/packages/react-charts/src/city/plumes.test.mjs b/packages/react-charts/src/city/plumes.test.mjs
new file mode 100644
index 00000000..9309965f
--- /dev/null
+++ b/packages/react-charts/src/city/plumes.test.mjs
@@ -0,0 +1,96 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import {
+ facadeWindows,
+ windowEmitters,
+ samplePlume,
+ makeParticles,
+} from './engine/plumes.js'
+import { sizeForUsers } from './engine/model.js'
+
+function sources(status = 'critical') {
+ const c = { id: 'test', x: 10, z: 20, users: 2200, status }
+ const size = sizeForUsers(c.users)
+ c.height = size.height
+ c.house = size.house
+ return {
+ windows: facadeWindows(c, size, 1.6, 1.5),
+ emitters: windowEmitters(c, facadeWindows(c, size, 1.6, 1.5)),
+ }
+}
+test('each emission belongs to a real window and originates inside the building', () => {
+ const { windows, emitters } = sources()
+ assert.ok(emitters.length > 0)
+ for (const e of emitters) {
+ assert.ok(windows.some((w) => w.x === e.x && w.y === e.y && w.z === e.z))
+ for (let seed = 0; seed < 100; seed++) {
+ const p = samplePlume(e, 0, seed)
+ const depth = (p.x - e.x) * e.nx + (p.z - e.z) * e.nz
+ assert.ok(depth < 0)
+ assert.ok(Math.abs(p.y - e.y) + p.radius < e.height / 2)
+ const side = -(p.x - e.x) * e.nz + (p.z - e.z) * e.nx
+ assert.ok(Math.abs(side) + p.radius < e.width / 2)
+ }
+ }
+})
+test('flow fits the opening then expands outward along the normal and rises through buoyancy', () => {
+ for (const { emitters } of [sources(), sources('warning')])
+ for (const e of emitters)
+ for (const kind of ['flame', 'smoke']) {
+ let previousY = -Infinity
+ for (let age = 0; age < 1.2; age += 0.01) {
+ const p = samplePlume(e, age, 3, kind)
+ assert.ok(p.y >= previousY - 1e-8)
+ previousY = p.y
+ if (p.outward < 0.035) {
+ const side = -(p.x - e.x) * e.nz + (p.z - e.z) * e.nx
+ assert.ok(Math.abs(side) + p.radius < e.width / 2)
+ assert.ok(Math.abs(p.y - e.y) + p.length < e.height / 2)
+ }
+ }
+ const birth = samplePlume(e, 0, 3, kind),
+ grown = samplePlume(e, 1, 3, kind)
+ assert.ok(grown.outward > 0)
+ assert.ok(grown.y > birth.y)
+ assert.ok(grown.radius > birth.radius)
+ }
+})
+test('medium risk produces only smoke; healthy buildings have no emitter', () => {
+ const warning = sources('warning')
+ assert.equal(makeParticles(warning.emitters, 'flame').length, 0)
+ assert.ok(makeParticles(warning.emitters, 'smoke').length > 0)
+ assert.equal(sources('healthy').emitters.length, 0)
+ const e = sources().emitters[0]
+ const narrow = { ...e, width: e.width / 2, rate: e.rate / 2 }
+ assert.ok(
+ makeParticles([narrow], 'flame').length <
+ makeParticles([e], 'flame').length,
+ )
+})
+
+test('larger buildings spread smoke across more windows and floors without introducing fire', () => {
+ let previous = 0
+ for (const users of [30, 300, 1000, 3000]) {
+ const size = sizeForUsers(users),
+ c = {
+ id: `warning-${users}`,
+ x: 0,
+ z: 0,
+ users,
+ status: 'warning',
+ height: size.height,
+ house: size.house,
+ }
+ const windows = facadeWindows(c, size, 1.5, 1.4),
+ emitters = windowEmitters(c, windows)
+ assert.ok(emitters.length > previous)
+ previous = emitters.length
+ assert.equal(new Set(emitters.map((e) => e.index)).size, emitters.length)
+ assert.ok(emitters.every((e) => windows.some((w) => w.index === e.index)))
+ if (!size.house) {
+ assert.ok(new Set(emitters.map((e) => e.y)).size > 1)
+ assert.ok(new Set(emitters.map((e) => e.nx)).size > 1)
+ }
+ assert.equal(makeParticles(emitters, 'flame').length, 0)
+ }
+})
diff --git a/packages/react-charts/src/city/react.d.ts b/packages/react-charts/src/city/react.d.ts
new file mode 100644
index 00000000..58a81e14
--- /dev/null
+++ b/packages/react-charts/src/city/react.d.ts
@@ -0,0 +1 @@
+export { CityChart } from './types'
diff --git a/packages/react-charts/src/city/react.jsx b/packages/react-charts/src/city/react.jsx
new file mode 100644
index 00000000..0b79de3a
--- /dev/null
+++ b/packages/react-charts/src/city/react.jsx
@@ -0,0 +1,258 @@
+import { cityStyles } from './styles'
+import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect.js'
+import { useMapI18n } from './use-map-i18n.js'
+import { useId, useRef, useState } from 'react'
+import { Chart } from '../core'
+import { regionsFor, categoryLabel, metricFor } from './engine/model.js'
+import { useCityChart } from './use-city-chart.js'
+import { EnvironmentControls } from './environment-controls.jsx'
+
+function DistrictLabels({ layout, labelsRef, labelNodes, visible }) {
+ const { format } = useMapI18n()
+ return (
+
+ {regionsFor(layout.companies)
+ .filter((region) => layout.anchors[region.mask])
+ .map((region) => (
+
{
+ if (node) labelNodes.current.set(region.mask, node)
+ else labelNodes.current.delete(region.mask)
+ }}
+ className={`district-label atlas-city-label atlas-city-label--${region.mask}`}
+ data-region={region.mask}
+ >
+
+ {region.shortLabel}
+
+ {format(
+ layout.companies.filter(
+ (company) => company.mask === region.mask,
+ ).length,
+ )}
+
+
+ ))}
+
+ )
+}
+function CompanyHover({ tooltip, onPointerEnter, onPointerLeave }) {
+ const { t, format } = useMapI18n()
+ const anchorId = `atlas-city-hover-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`
+ const contentRef = useRef(null)
+ const positionRef = useRef(null)
+ const arrowRef = useRef(null)
+ useIsomorphicLayoutEffect(() => {
+ if (!tooltip || !contentRef.current) return
+ const content = contentRef.current
+ const update = () => {
+ const width = content.parentElement.clientWidth
+ const height = content.parentElement.clientHeight
+ const box = content.getBoundingClientRect()
+ const left = Math.max(
+ 12,
+ Math.min(tooltip.x - 16, width - box.width - 12),
+ )
+ const above = tooltip.y - box.height - 5 >= 12
+ const top = Math.max(
+ 12,
+ Math.min(
+ above ? tooltip.y - box.height - 5 : tooltip.y + 5,
+ height - box.height - 12,
+ ),
+ )
+ const rules = positionRef.current.sheet.cssRules
+ rules[1].style.left = `${left}px`
+ rules[1].style.top = `${top}px`
+ rules[2].style.left = `${tooltip.x + 0.5 - left - content.clientLeft - 5}px`
+ const bottomBorder =
+ parseFloat(getComputedStyle(content).borderBottomWidth) || 0
+ rules[2].style.top = above
+ ? `calc(100% + ${bottomBorder}px)`
+ : `${-5 - content.clientTop}px`
+ arrowRef.current.setAttribute(
+ 'd',
+ above ? 'M 0 -1 L 5 5 L 10 -1' : 'M 0 6 L 5 0 L 10 6',
+ )
+ }
+ update()
+ const observer = new ResizeObserver(update)
+ observer.observe(content)
+ return () => observer.disconnect()
+ }, [tooltip])
+ if (!tooltip) return null
+ return (
+ <>
+
+
+
+
{tooltip.company.name}
+
+ {categoryLabel(tooltip.company)} · {metricFor(tooltip.company).label}:{' '}
+ {format(metricFor(tooltip.company).value)} ·{' '}
+ {t(`status.${tooltip.company.status}`)}
+
+
+
+
+
+ >
+ )
+}
+// Standard TanStack definition and ChartPoint callbacks, with a Three.js surface.
+export function CityChart({
+ definition,
+ id,
+ viewportId,
+ chartRef,
+ onSelect,
+ onFocusChange,
+ view = 'iso',
+ life = true,
+ lightingMode = 'auto',
+ weather = 'none',
+ showEnvironmentControls = false,
+ effects = true,
+ autoRotate = false,
+ showDistrictLabels = false,
+ region = 'all',
+ busy = false,
+ selected = null,
+ children,
+ ariaLabel: ariaLabelProp,
+ className = '',
+ theme = 'default',
+}) {
+ const { t, locale } = useMapI18n()
+ const ariaLabel = ariaLabelProp ?? t('chartLabel')
+ const instanceId = useId()
+ const projectionStyleRef = useRef(null)
+ const panelRef = useRef(null)
+ const viewportRef = useRef(null)
+ const labelsRef = useRef(null)
+ const labelNodes = useRef(new Map())
+ const [tooltip, setTooltip] = useState(null)
+ const [lightingOverride, setLightingOverride] = useState('auto')
+ const [weatherOverride, setWeatherOverride] = useState('auto')
+ const weatherMode = showEnvironmentControls ? weatherOverride : 'auto'
+ const {
+ renderer,
+ layout,
+ ready,
+ error,
+ onRender,
+ onTooltipEnter,
+ onTooltipLeave,
+ } = useCityChart({
+ definition,
+ busy,
+ selected,
+ region,
+ currentView: view,
+ life,
+ lightingMode:
+ showEnvironmentControls && lightingOverride !== 'auto'
+ ? lightingOverride
+ : lightingMode,
+ weather: weatherMode === 'auto' ? weather : weatherMode,
+ effects,
+ autoRotate,
+ chartRef,
+ panelRef,
+ viewportRef,
+ labelsRef,
+ labelNodes,
+ onHover: setTooltip,
+ projectionStyleRef,
+ instanceId,
+ })
+ const environmentControls = showEnvironmentControls ? (
+
+ ) : null
+ return (
+
+
+
+
+
+
+
+
+ {typeof children === 'function' ? (
+ children({ ready, error, environmentControls, weatherMode })
+ ) : (
+ <>
+ {children}
+ {environmentControls && (
+
{environmentControls}
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/packages/react-charts/src/city/renderer.js b/packages/react-charts/src/city/renderer.js
new file mode 100644
index 00000000..7a77614d
--- /dev/null
+++ b/packages/react-charts/src/city/renderer.js
@@ -0,0 +1,306 @@
+import { renderChartSvg } from '@tanstack/charts/svg'
+import { getBrowserMapLocale, getMapI18n } from './i18n.js'
+import { City } from './engine/city.js'
+import { readCityLayout } from './layout.js'
+import { mapTooltipAnchor } from './engine/lib/map-tooltip.js'
+import { bindCityPoints } from './interaction.js'
+
+const escapeAttribute = (text) =>
+ String(text).replace(
+ /[&<>"']/g,
+ (char) =>
+ ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ })[char],
+ )
+
+/**
+ * TanStack ChartRenderer backed by one Three.js city per mounted surface.
+ * Pass this renderer to Chart from @tanstack/charts/react/core or to
+ * mountChartRenderer. Data, color scales, focus, tooltips and selection remain
+ * TanStack contracts; only painting and exact spatial hit testing are 3D.
+ */
+export function createCityRenderer({
+ view = 'iso',
+ life = true,
+ autoRotate = false,
+ effects = true,
+ onMount,
+ onData,
+ onDestroy,
+ onError,
+ ...callbacks
+} = {}) {
+ const renderer = {
+ id: 'atlas-city-3d',
+ prerender(scene, options) {
+ return `${renderChartSvg(scene, { ...options, tabIndex: -1 })}
`
+ },
+ mount(container) {
+ const element =
+ container.querySelector('[data-city-surface]') ||
+ container.ownerDocument.createElement('div')
+ element.dataset.citySurface = ''
+ element.style.width = '100%'
+ element.style.height = '100%'
+ element.style.position = 'relative'
+ const fallback =
+ element.querySelector('[data-city-fallback]') ||
+ container.ownerDocument.createElement('div')
+ fallback.dataset.cityFallback = ''
+ Object.assign(fallback.style, {
+ position: 'absolute',
+ inset: '0',
+ pointerEvents: 'none',
+ })
+ if (!fallback.parentNode) element.prepend(fallback)
+ if (!element.parentNode) container.append(element)
+ let city
+ let scene
+ let sourcePoints = []
+ let presentationPoints = []
+ let cameraKey
+ let dataKey
+ let hitCache
+ let pointsById = new Map()
+ let pointerDown
+ let dragging = false
+ let destroyed = false
+ const listeners = new Set()
+
+ const pickAt = (clientX, clientY) => {
+ const box = element.getBoundingClientRect()
+ const key = [
+ Math.round((clientX - box.left) * 1e6),
+ Math.round((clientY - box.top) * 1e6),
+ box.width,
+ box.height,
+ city.cameraRevision,
+ ].join(':')
+ if (hitCache?.key !== key)
+ hitCache = { key, company: city.pick({ clientX, clientY }) }
+ return hitCache.company
+ }
+ const resolveHit = (x, y) => {
+ if (destroyed || !city || !scene || dragging || !city.controls.enabled)
+ return []
+ const box = element.getBoundingClientRect()
+ const company = pickAt(
+ box.left + (x * box.width) / scene.width,
+ box.top + (y * box.height) / scene.height,
+ )
+ const point = company && pointsById.get(company.id)
+ return point ? [point] : []
+ }
+ const updatePresentation = () => {
+ if (!city || !scene) return
+ const nextKey = [scene.width, scene.height, city.cameraRevision].join(
+ ',',
+ )
+ if (cameraKey === nextKey) return
+ cameraKey = nextKey
+ presentationPoints = sourcePoints.map((point) => {
+ const company = city.companies[point.datumIndex]
+ const projected = city.project(company.x, company.roofTop, company.z)
+ const anchor = mapTooltipAnchor(projected, scene.width, scene.height)
+ return { ...point, x: anchor?.x ?? -1e6, y: anchor?.y ?? -1e6 }
+ })
+ pointsById = new Map(presentationPoints.map((p) => [p.datum.id, p]))
+ bindCityPoints(presentationPoints, resolveHit)
+ for (const listener of listeners) listener(presentationPoints)
+ callbacks.projection?.(city)
+ }
+ const pointerStart = (event) => {
+ pointerDown = { x: event.clientX, y: event.clientY }
+ dragging = false
+ }
+ const pointerMove = (event) => {
+ if (
+ event.buttons &&
+ pointerDown &&
+ Math.hypot(
+ event.clientX - pointerDown.x,
+ event.clientY - pointerDown.y,
+ ) >= 5
+ )
+ dragging = true
+ // OrbitControls has already handled this event on the canvas.
+ if (event.buttons) event.stopPropagation()
+ else if (city) {
+ const company = pickAt(event.clientX, event.clientY)
+ element.style.cursor = company ? 'pointer' : 'grab'
+ callbacks.hover?.(company, { event })
+ }
+ }
+ const pointerLeave = () => callbacks.hover?.(null)
+ const click = (event) => {
+ if (dragging || !city?.controls.enabled) event.stopPropagation()
+ dragging = false
+ pointerDown = null
+ }
+ element.addEventListener('pointerdown', pointerStart)
+ element.addEventListener('pointermove', pointerMove)
+ element.addEventListener('pointerleave', pointerLeave)
+ element.addEventListener('click', click)
+ try {
+ city = new City(element, {
+ ...callbacks,
+ frame(value) {
+ updatePresentation()
+ callbacks.frame?.(value)
+ },
+ })
+ Object.assign(city.renderer.domElement.style, {
+ position: 'relative',
+ zIndex: '1',
+ })
+ city.setActivity({ life, effects })
+ city.setAutoRotate(autoRotate)
+ onMount?.(city)
+ } catch (error) {
+ city?.dispose()
+ city = null
+ if (!onError) {
+ element.remove()
+ throw error
+ }
+ onError(error)
+ }
+ return {
+ renderer,
+ element,
+ render(nextScene, options) {
+ scene = nextScene
+ sourcePoints = scene.points
+ // Keep the standard semantic SVG for SSR, accessibility and WebGL failure.
+ // The opaque WebGL canvas paints above it when available.
+ fallback.innerHTML = renderChartSvg(scene, {
+ ...options,
+ tabIndex: -1,
+ })
+ if (new Set(sourcePoints.map((point) => point.markId)).size > 1)
+ throw new TypeError(
+ 'CityRenderer aceita uma cityMark por superfície.',
+ )
+ element.setAttribute('role', 'group')
+ element.setAttribute(
+ 'aria-roledescription',
+ getMapI18n(getBrowserMapLocale()).t('chartRole'),
+ )
+ element.setAttribute('aria-label', options.ariaLabel)
+ element.tabIndex = options.tabIndex ?? 0
+ if (options.ariaDescription)
+ element.setAttribute('aria-description', options.ariaDescription)
+ else element.removeAttribute('aria-description')
+ if (!city) return
+ const sharedLayout = readCityLayout(scene.nodes)
+ if (!sharedLayout)
+ throw new TypeError('CityRenderer requer uma cityMark.')
+ const nextKey = JSON.stringify([
+ sharedLayout.roadBounds,
+ sharedLayout.metricDomain,
+ sharedLayout.anchors,
+ sharedLayout.center,
+ sourcePoints.map((point, i) => [
+ point.datum.id,
+ point.datum.name,
+ point.datum.metric,
+ point.datum.users,
+ point.datum.categories,
+ sharedLayout.companies[i].mask,
+ point.datum.mask,
+ point.datum.status,
+ point.color,
+ sharedLayout.companies[i].x,
+ sharedLayout.companies[i].z,
+ sharedLayout.companies[i].col,
+ sharedLayout.companies[i].row,
+ ]),
+ ])
+ if (nextKey !== dataKey) {
+ try {
+ // Copy only engine rows; both mounts share the immutable layout.
+ const layout = {
+ ...sharedLayout,
+ companies: sourcePoints.map((p, i) => ({
+ ...p.datum,
+ ...sharedLayout.companies[i],
+ x: sharedLayout.companies[i].x,
+ z: sharedLayout.companies[i].z,
+ col: sharedLayout.companies[i].col,
+ row: sharedLayout.companies[i].row,
+ })),
+ }
+ const resetView = city.updateCompanies(
+ layout,
+ new Map(
+ sourcePoints.map((p, i) => [
+ sharedLayout.companies[i].mask,
+ p.color,
+ ]),
+ ),
+ )
+ if (resetView) city.view(view, true)
+ dataKey = nextKey
+ hitCache = null
+ onData?.(city, { resetView })
+ } catch (error) {
+ if (!onError) throw error
+ onError(error)
+ }
+ }
+ city.resize()
+ cameraKey = null
+ updatePresentation()
+ },
+ clientToScene(currentScene, clientX, clientY) {
+ const box = element.getBoundingClientRect()
+ if (!box.width || !box.height) return null
+ return {
+ x: ((clientX - box.left) * currentScene.width) / box.width,
+ y: ((clientY - box.top) * currentScene.height) / box.height,
+ }
+ },
+ getPresentationPoints() {
+ updatePresentation()
+ return presentationPoints
+ },
+ subscribePresentationPoints(listener) {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ paintFocus(focus) {
+ if (focus?.source === 'keyboard' && city) {
+ const company = city.companiesById.get(focus.primary?.datum.id)
+ if (company) city.select(company)
+ }
+ },
+ destroy() {
+ if (destroyed) return
+ destroyed = true
+ listeners.clear()
+ element.removeEventListener('pointerdown', pointerStart)
+ element.removeEventListener('pointermove', pointerMove)
+ element.removeEventListener('pointerleave', pointerLeave)
+ element.removeEventListener('click', click)
+ if (city) {
+ onDestroy?.(city)
+ city.dispose()
+ city = null
+ }
+ sourcePoints = []
+ presentationPoints = []
+ pointsById.clear()
+ hitCache = null
+ scene = null
+ element.remove()
+ },
+ }
+ },
+ }
+ return renderer
+}
diff --git a/packages/react-charts/src/city/scene-resources.test.mjs b/packages/react-charts/src/city/scene-resources.test.mjs
new file mode 100644
index 00000000..3dbfd48f
--- /dev/null
+++ b/packages/react-charts/src/city/scene-resources.test.mjs
@@ -0,0 +1,40 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import * as THREE from 'three'
+import { disposeSceneGroups } from './engine/scene-resources.js'
+
+test('removed cities release shared resources once and all instance buffers', () => {
+ for (let page = 0; page < 20; page++) {
+ const terrain = new THREE.Group(),
+ buildings = new THREE.Group(),
+ geometry = new THREE.BoxGeometry()
+ const texture = new THREE.Texture(),
+ material = new THREE.MeshStandardMaterial({ map: texture })
+ const counts = { geometry: 0, material: 0, texture: 0, instances: 0 }
+ geometry.addEventListener('dispose', () => counts.geometry++)
+ material.addEventListener('dispose', () => counts.material++)
+ texture.addEventListener('dispose', () => counts.texture++)
+ for (const group of [terrain, buildings])
+ for (let i = 0; i < 3; i++) {
+ const mesh = new THREE.InstancedMesh(geometry, material, 100)
+ mesh.addEventListener('dispose', () => counts.instances++)
+ group.add(mesh)
+ }
+ disposeSceneGroups([terrain, undefined, buildings])
+ assert.equal(terrain.children.length, 0)
+ assert.equal(buildings.children.length, 0)
+ assert.deepEqual(counts, {
+ geometry: 1,
+ material: 1,
+ texture: 1,
+ instances: 6,
+ })
+ disposeSceneGroups([terrain, buildings])
+ assert.deepEqual(counts, {
+ geometry: 1,
+ material: 1,
+ texture: 1,
+ instances: 6,
+ })
+ }
+})
diff --git a/packages/react-charts/src/city/styles.ts b/packages/react-charts/src/city/styles.ts
new file mode 100644
index 00000000..dd5b4e8e
--- /dev/null
+++ b/packages/react-charts/src/city/styles.ts
@@ -0,0 +1,745 @@
+// Scoped presentation owned by the React renderer.
+export const cityStyles = `/* Zero specificity: a consumer class can override tokens in any import order. */
+:where(.atlas-city-theme-default) {
+ --atlas-city-background: #e9ede4;
+ --atlas-city-card: #fff;
+ --atlas-city-text: #0f172a;
+ --atlas-city-border: #e2e8f0;
+ --atlas-city-muted: #1e293b;
+ --atlas-city-overlay-text: #000;
+ --atlas-city-surface-tint: #e2e8f0;
+ --atlas-city-surface-background: color-mix(
+ in srgb,
+ var(--atlas-city-surface-tint) 68%,
+ transparent
+ );
+ --atlas-city-surface-hover-background: color-mix(
+ in srgb,
+ var(--atlas-city-surface-tint) 78%,
+ transparent
+ );
+ --atlas-city-surface-blur: 10px;
+ --atlas-city-selected-background: color-mix(
+ in srgb,
+ color-mix(in srgb, var(--atlas-city-accent) 70%, #000) 88%,
+ transparent
+ );
+ --atlas-city-selected-hover-background: color-mix(
+ in srgb,
+ var(--atlas-city-accent-hover) 88%,
+ transparent
+ );
+ --atlas-city-accent: #1f72ea;
+ --atlas-city-accent-hover: color-mix(
+ in srgb,
+ var(--atlas-city-accent) 60%,
+ #000
+ );
+ --atlas-city-on-accent: #fff;
+ --atlas-city-tooltip-background: var(--atlas-city-surface-background);
+ --atlas-city-tooltip-text: #0f172a;
+ --atlas-city-font-family:
+ system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ --atlas-city-font-size: 12px;
+ --atlas-city-radius: 6px;
+ --atlas-city-panel-radius: 8px;
+ --atlas-city-shadow: 0 4px 6px #0001;
+ --atlas-city-control-padding: 8px 12px;
+ --atlas-city-height: 560px;
+ --atlas-city-region-1: #4b91ef;
+ --atlas-city-region-2: #e281af;
+ --atlas-city-region-3: #ab7cdd;
+ --atlas-city-region-4: #ecc456;
+ --atlas-city-region-5: #47b8b2;
+ --atlas-city-region-6: #e99b55;
+ --atlas-city-region-7: #7777ca;
+}
+
+
+
+.atlas-city-chart,
+.atlas-city-map {
+ position: relative;
+ min-width: 0;
+ color: var(--atlas-city-text);
+ font-family: var(--atlas-city-font-family);
+ font-size: var(--atlas-city-font-size);
+ line-height: 1.5;
+ background: var(--atlas-city-background);
+}
+.atlas-city-chart {
+ flex: 1;
+ min-height: 520px;
+ overflow: hidden;
+}
+.atlas-city-chart:has(> .atlas-map-layout) {
+ display: flex;
+ flex-direction: column;
+ flex: 1 0 auto;
+ min-height: min-content;
+}
+.atlas-map-layout {
+ min-height: 520px;
+}
+.atlas-city-map {
+ min-height: var(--atlas-city-height);
+}
+.atlas-city-map > .atlas-city-chart {
+ display: flex;
+ flex-direction: column;
+ min-height: var(--atlas-city-height);
+}
+.atlas-city-map:fullscreen {
+ width: 100%;
+ height: 100%;
+}
+.atlas-city-viewport,
+.atlas-city-labels {
+ position: absolute;
+ inset: 0;
+}
+.atlas-city-viewport {
+ touch-action: none;
+ outline: none;
+}
+.atlas-city-viewport canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+.atlas-city-labels {
+ pointer-events: none;
+ overflow: hidden;
+}
+.atlas-city-labels[hidden] {
+ display: none;
+}
+.atlas-city-label {
+ position: absolute;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transform: translate(-50%, -50%);
+ padding: 6px 8px;
+ border: 1px solid var(--atlas-city-border);
+ border-radius: var(--atlas-city-radius);
+ background: var(--atlas-city-surface-background);
+ box-shadow: var(--atlas-city-shadow);
+ font-size: 10px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+.atlas-city-label-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 2px;
+}
+.atlas-city-label small {
+ color: var(--atlas-city-muted);
+ font-size: 9px;
+ font-weight: 400;
+}
+.atlas-city-label-name {
+ color: var(--atlas-city-text);
+}
+.atlas-city-chart .text-muted-foreground,
+.atlas-city-chart .section-label {
+ color: var(--atlas-city-muted);
+}
+.atlas-city-title,
+.atlas-map-heading,
+.atlas-map-heading .text-muted-foreground,
+.atlas-city-chart .atlas-map-instructions,
+.atlas-city-chart .atlas-map-compass,
+.atlas-map-footer-text {
+ color: var(--atlas-city-overlay-text);
+}
+.atlas-city-chart [data-variant='default'] {
+ background: var(--atlas-city-selected-background);
+ color: var(--atlas-city-on-accent);
+}
+.atlas-city-chart [data-variant='default']:enabled:hover,
+.atlas-city-map button[aria-pressed='true']:enabled:hover {
+ background: var(--atlas-city-selected-hover-background);
+}
+.atlas-city-tooltip {
+ position: absolute;
+ z-index: 50;
+ max-width: min(256px, calc(100% - 24px));
+ padding: var(--atlas-city-control-padding);
+ border: 1px solid var(--atlas-city-border);
+ border-radius: var(--atlas-city-radius);
+ background: var(--atlas-city-tooltip-background);
+ color: var(--atlas-city-tooltip-text);
+ box-shadow: var(--atlas-city-shadow);
+ font-family: var(--atlas-city-font-family);
+ font-size: 11px;
+ pointer-events: auto;
+}
+.atlas-city-tooltip small {
+ display: block;
+ margin-top: 4px;
+ font-size: 9px;
+ color: var(--atlas-city-muted);
+}
+.atlas-city-control {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: var(--atlas-city-control-padding);
+ border: 1px solid var(--atlas-city-border);
+ border-radius: var(--atlas-city-radius);
+ background: var(--atlas-city-surface-background);
+ color: var(--atlas-city-text);
+ font-size: var(--atlas-city-font-size);
+ pointer-events: auto;
+}
+.atlas-city-control:has(:disabled) {
+ opacity: 0.5;
+}
+.atlas-city-select {
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ color: var(--atlas-city-text);
+ font: inherit;
+ cursor: pointer;
+}
+.atlas-city-environment,
+.atlas-city-scene-controls {
+ position: absolute;
+ right: 16px;
+ bottom: 16px;
+ left: 16px;
+ z-index: 10;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+ pointer-events: none;
+}
+.atlas-city-map button,
+.atlas-city-map input,
+.atlas-city-map fieldset {
+ box-sizing: border-box;
+ margin: 0;
+ font: inherit;
+}
+.atlas-city-map button,
+.atlas-city-search input {
+ padding: var(--atlas-city-control-padding);
+ border: 1px solid var(--atlas-city-border);
+ border-radius: var(--atlas-city-radius);
+ background: var(--atlas-city-surface-background);
+ color: var(--atlas-city-text);
+}
+.atlas-city-search-input::placeholder {
+ color: var(--atlas-city-muted);
+ opacity: 1;
+}
+.atlas-city-map button {
+ cursor: pointer;
+}
+.atlas-city-map button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+.atlas-city-map button:focus-visible,
+.atlas-city-map input:focus-visible,
+.atlas-city-select:focus-visible {
+ outline: 2px solid var(--atlas-city-accent);
+ outline-offset: 2px;
+}
+.atlas-city-map button[aria-pressed='true'] {
+ background: var(--atlas-city-selected-background);
+ color: var(--atlas-city-on-accent);
+}
+.atlas-city-top-controls {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ left: 16px;
+ z-index: 11;
+ display: flex;
+ flex-direction: column;
+ gap: var(--atlas-city-overlay-gap, 12px);
+ pointer-events: none;
+}
+.atlas-city-top-controls[hidden],
+.atlas-city-scene-controls[hidden] {
+ display: none;
+}
+.atlas-city-header {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: var(--atlas-city-overlay-gap, 12px);
+}
+.atlas-city-title {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+.atlas-city-search {
+ position: relative;
+ flex-shrink: 0;
+ width: min(240px, 55%);
+ pointer-events: auto;
+}
+.atlas-city-search input {
+ width: 100%;
+}
+.atlas-city-results {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ left: 0;
+ padding: 4px;
+ border: 1px solid var(--atlas-city-border);
+ border-radius: var(--atlas-city-radius);
+ background: var(--atlas-city-surface-background);
+}
+.atlas-city-results button {
+ display: block;
+ width: 100%;
+ text-align: left;
+}
+.atlas-city-perspectives {
+ align-self: flex-start;
+ display: flex;
+ gap: 4px;
+ padding: 4px;
+ border: 0;
+ pointer-events: auto;
+ border-radius: var(--atlas-city-radius);
+ background: var(--atlas-city-surface-background);
+ backdrop-filter: blur(var(--atlas-city-surface-blur));
+}
+.atlas-city-perspectives legend {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+}
+.atlas-city-zoom {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ right: 16px;
+ z-index: 10;
+ display: grid;
+ gap: 4px;
+}
+.atlas-city-zoom button {
+ display: grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ font-size: 22px;
+}
+.atlas-city-control input {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--atlas-city-accent);
+}
+.atlas-city-details,
+.atlas-city-message {
+ align-self: flex-start;
+ max-width: min(280px, calc(100% - 64px));
+ box-sizing: border-box;
+ padding: 16px;
+ border: 1px solid var(--atlas-city-border);
+ border-radius: var(--atlas-city-panel-radius);
+ background: var(--atlas-city-surface-background);
+ pointer-events: auto;
+}
+.atlas-city-message {
+ margin: 0;
+}
+.atlas-city-details > button:first-child {
+ float: right;
+ margin-left: 8px;
+}
+.atlas-city-loading {
+ position: absolute;
+ inset: 0;
+ z-index: 30;
+ display: grid;
+ place-content: center;
+ padding: 24px;
+ background: var(--atlas-city-surface-background);
+ text-align: center;
+}
+@media (min-width: 1024px) {
+ .atlas-city-chart {
+ min-height: 460px;
+ }
+ .atlas-map-layout {
+ min-height: 460px;
+ }
+}
+
+.atlas-city-host {
+ /* Override the TanStack host's inline 320px placeholder; fill our viewport. */
+ height: 100% !important;
+}
+.atlas-city-label[hidden] {
+ display: none;
+}
+.atlas-city-label[data-dimmed='true'] {
+ opacity: 0.2;
+}
+.atlas-city-label-dot {
+ background: currentColor;
+}
+.atlas-city-label--1 {
+ color: var(--atlas-city-region-1);
+}
+.atlas-city-label--2 {
+ color: var(--atlas-city-region-2);
+}
+.atlas-city-label--3 {
+ color: var(--atlas-city-region-3);
+}
+.atlas-city-label--4 {
+ color: var(--atlas-city-region-4);
+}
+.atlas-city-label--5 {
+ color: var(--atlas-city-region-5);
+}
+.atlas-city-label--6 {
+ color: var(--atlas-city-region-6);
+}
+.atlas-city-label--7 {
+ color: var(--atlas-city-region-7);
+}
+
+.atlas-city-tooltip-anchor {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ pointer-events: none;
+}
+.atlas-city-tooltip-arrow {
+ position: absolute;
+ overflow: visible;
+ fill: var(--atlas-city-tooltip-background);
+ stroke: var(--atlas-city-border);
+}
+.atlas-city-chart .city-slide-snapshot {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ background: var(--atlas-city-background);
+ overflow: hidden;
+}
+.atlas-city-chart .city-slide-snapshot > canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+.atlas-city-chart .city-slide-labels {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ overflow: hidden;
+}
+.atlas-city-chart.city-sliding .city-chart-viewport {
+ pointer-events: none;
+}
+
+.atlas-city-label,
+.atlas-city-tooltip,
+.atlas-city-control,
+.atlas-city-search-input,
+.atlas-city-results,
+.atlas-city-camera-button,
+.atlas-city-details,
+.atlas-city-message,
+.atlas-city-loading {
+ backdrop-filter: blur(var(--atlas-city-surface-blur));
+}
+.atlas-city-map .atlas-city-perspective-button,
+.atlas-city-map .atlas-city-search-result,
+.atlas-city-map .atlas-city-details-close {
+ background: transparent;
+}
+.atlas-city-map button:enabled:hover {
+ background: var(--atlas-city-surface-hover-background);
+}
+.atlas-city-map .atlas-city-perspective-button[aria-pressed='true'] {
+ background: var(--atlas-city-selected-background);
+}
+.atlas-city-map
+ .atlas-city-perspective-button[aria-pressed='true']:enabled:hover {
+ background: var(--atlas-city-selected-hover-background);
+}
+.atlas-city-select-option {
+ background: var(--atlas-city-card);
+ color: var(--atlas-city-text);
+}
+.atlas-city-chart
+ :is(
+ .atlas-map-search-input,
+ .atlas-map-perspectives,
+ .atlas-map-zoom,
+ .atlas-map-toggle,
+ .atlas-map-details,
+ .atlas-map-message,
+ .atlas-map-loading,
+ .atlas-map-loading-card
+ ) {
+ background: var(--atlas-city-surface-background);
+ backdrop-filter: blur(var(--atlas-city-surface-blur));
+ color: var(--atlas-city-text);
+}
+.atlas-city-chart .atlas-map-search-input::placeholder {
+ color: var(--atlas-city-muted);
+ opacity: 1;
+}
+.atlas-city-chart .atlas-map-perspective-option {
+ color: var(--atlas-city-muted);
+}
+.atlas-city-chart .atlas-map-perspectives input:checked + span {
+ background: var(--atlas-city-selected-background);
+ color: var(--atlas-city-on-accent);
+}
+.atlas-city-chart .atlas-map-camera-button:enabled:hover,
+.atlas-city-chart .atlas-map-details [data-variant='ghost']:enabled:hover {
+ background: var(--atlas-city-surface-hover-background);
+ color: var(--atlas-city-text);
+}
+.atlas-city-chart .atlas-map-compass-arrow {
+ transform: rotate(var(--atlas-city-north-angle, 0rad));
+}
+.atlas-city-chart .atlas-map-compass {
+ color: var(--atlas-city-monitor-text, var(--atlas-city-overlay-text));
+}
+
+.atlas-company-monitor {
+ align-self: flex-start;
+ width: 272px;
+ max-width: calc(100% - 56px);
+ color: var(--atlas-city-monitor-text, var(--atlas-city-overlay-text));
+ pointer-events: auto;
+}
+.atlas-monitor-clock {
+ display: grid;
+ gap: 2px;
+ font-size: 10px;
+ color: inherit;
+}
+.atlas-monitor-clock > time {
+ font-family: ui-monospace, monospace;
+ font-size: clamp(30px, 4vw, 48px);
+ font-weight: 600;
+ line-height: 1.2;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.05em;
+ color: inherit;
+}
+.atlas-monitor-metrics,
+.atlas-monitor-alerts {
+ border-top: 1px solid currentColor;
+ margin-top: 12px;
+ padding-top: 12px;
+}
+.atlas-monitor-metrics {
+ display: grid;
+ grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
+ align-items: center;
+ gap: 12px;
+}
+.atlas-monitor-alerts h2 {
+ font-size: 12px;
+ font-weight: 600;
+}
+.atlas-monitor-caption,
+.atlas-monitor-empty {
+ font-size: 10px;
+ line-height: 1.5;
+ color: inherit;
+ margin-top: 4px;
+}
+.atlas-monitor-alerts ol {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-rows: repeat(2, auto);
+ grid-auto-flow: column;
+ gap: 8px 12px;
+ list-style: none;
+ padding: 0;
+ margin: 8px 0 0;
+}
+.atlas-monitor-alerts li {
+ display: grid;
+ min-width: 0;
+ align-content: start;
+ gap: 2px;
+ border-left: 2px solid var(--atlas-city-risk-critical, #db654d);
+ padding-left: 8px;
+ font-size: 11px;
+}
+.atlas-monitor-alerts li strong {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 500;
+}
+.atlas-monitor-alerts li time {
+ font-size: 10px;
+ color: inherit;
+}
+
+.atlas-monitor-metrics p {
+ margin: 0;
+ font-size: 10px;
+}
+.atlas-monitor-total {
+ display: block;
+ margin: 4px 0;
+ font-size: 36px;
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.05em;
+}
+.atlas-monitor-statuses {
+ display: grid;
+ gap: 8px;
+}
+.atlas-monitor-status {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.atlas-monitor-status > span {
+ width: 6px;
+ height: 6px;
+ flex-shrink: 0;
+ border-radius: 50%;
+ background: var(--atlas-city-risk-healthy, #4c9878);
+}
+.atlas-monitor-status--warning > span {
+ background: var(--atlas-city-risk-warning, #c28b29);
+}
+.atlas-monitor-status--critical > span {
+ background: var(--atlas-city-risk-critical, #db654d);
+}
+.atlas-city-layout {
+ position: relative;
+ z-index: 10;
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ gap: 24px;
+ box-sizing: border-box;
+ min-height: var(--atlas-city-height);
+ padding: 16px 16px 44px;
+ pointer-events: none;
+}
+.atlas-city-layout > .atlas-city-top-controls {
+ position: relative;
+ inset: auto;
+ flex-shrink: 0;
+}
+.atlas-city-monitor-layout {
+ flex-shrink: 0;
+}
+.atlas-city-monitor-layout .atlas-company-monitor {
+ max-width: calc(100% - 56px);
+}
+.atlas-city-monitor-layout .atlas-monitor-alerts h2 {
+ margin: 0;
+}
+.atlas-city-bottom-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-top: auto;
+}
+.atlas-city-selection:empty {
+ display: none;
+}
+.atlas-city-selection .atlas-city-details {
+ width: min(310px, calc(100% - 56px));
+ max-width: none;
+ margin-left: auto;
+ margin-right: 56px;
+}
+.atlas-city-details-dates {
+ font-size: 10px;
+}
+.atlas-city-details-dates dd {
+ margin: 0 0 8px;
+}
+.atlas-city-map .atlas-city-compass {
+ align-self: flex-start;
+ flex-shrink: 0;
+ pointer-events: auto;
+ display: grid;
+ place-items: center;
+ min-width: 44px;
+ min-height: 44px;
+ padding: 4px;
+ border: 0;
+ background: transparent;
+ font-size: 9px;
+}
+.atlas-city-compass .atlas-map-compass-arrow {
+ font-size: 30px;
+ line-height: 1;
+}
+.atlas-city-footer {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 10;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 4px 12px;
+ padding: 6px 16px;
+ border-top: 1px solid var(--atlas-city-border);
+ background: var(--atlas-city-surface-background);
+ color: var(--atlas-city-text);
+ font-size: 9px;
+ pointer-events: none;
+}
+.atlas-city-map .atlas-city-scene-controls {
+ position: static;
+ flex-shrink: 0;
+}
+.atlas-city-scene-controls[data-controls-visible='false'] {
+ visibility: hidden;
+}
+.atlas-city-map:fullscreen > .atlas-city-chart {
+ min-height: 100%;
+}
+.atlas-city-map:fullscreen .atlas-city-layout {
+ min-height: 100vh;
+}
+.atlas-city-map:fullscreen {
+ overflow: auto;
+}
+@media (min-width: 640px) {
+ .atlas-city-layout {
+ padding: 24px 24px 44px;
+ }
+ .atlas-city-zoom {
+ right: 24px;
+ }
+}
+@media (max-width: 639px) {
+ .atlas-city-header {
+ flex-direction: column;
+ }
+ .atlas-city-search {
+ width: 100%;
+ }
+}
+`
diff --git a/packages/react-charts/src/city/three/building-material.js b/packages/react-charts/src/city/three/building-material.js
new file mode 100644
index 00000000..e35beeb6
--- /dev/null
+++ b/packages/react-charts/src/city/three/building-material.js
@@ -0,0 +1,39 @@
+import * as THREE from 'three'
+import { material } from './primitives.js'
+
+// One flag per instance keeps the risk signal unlit without extra draw calls.
+export function buildingMaterial(geometry, count) {
+ geometry.setAttribute(
+ 'criticalBlink',
+ new THREE.InstancedBufferAttribute(
+ new Float32Array(Math.max(1, count)),
+ 1,
+ ).setUsage(THREE.DynamicDrawUsage),
+ )
+ const result = material('#ffffff')
+ result.onBeforeCompile = (shader) => {
+ shader.uniforms.criticalColor = { value: new THREE.Color('#ff2525') }
+ shader.vertexShader = shader.vertexShader
+ .replace(
+ '#include ',
+ '#include \nattribute float criticalBlink;\nvarying float vCriticalBlink;',
+ )
+ .replace(
+ '#include ',
+ '#include \nvCriticalBlink = criticalBlink;',
+ )
+ shader.fragmentShader = shader.fragmentShader
+ .replace(
+ '#include ',
+ '#include \nuniform vec3 criticalColor;\nvarying float vCriticalBlink;',
+ )
+ .replace(
+ '#include ',
+ `#include
+ if (vCriticalBlink > 0.5) gl_FragColor.rgb = criticalColor;`,
+ )
+ }
+ // The override runs after exposure/tone mapping, before output color space.
+ result.customProgramCacheKey = () => 'atlas-building-critical-color-v1'
+ return result
+}
diff --git a/packages/react-charts/src/city/three/buildings.js b/packages/react-charts/src/city/three/buildings.js
new file mode 100644
index 00000000..f71af301
--- /dev/null
+++ b/packages/react-charts/src/city/three/buildings.js
@@ -0,0 +1,127 @@
+import * as THREE from 'three'
+import {
+ random,
+ metricFor,
+ metricDomainFor,
+ sizeForValue,
+} from '../engine/model.js'
+import { facadeWindows, windowEmitters } from '../engine/plumes.js'
+import { createCriticalBlink } from '../engine/critical-blink.js'
+import { instances, material } from './primitives.js'
+import { updateWindowLighting } from './lighting.js'
+import { buildingMaterial } from './building-material.js'
+
+// One box per company, one plane per window; health never changes its size.
+export function buildBuildings(city, companies) {
+ const bodies = [],
+ windows = [],
+ glows = []
+ const rng = random(712)
+ const domain = city.layout?.metricDomain ?? metricDomainFor(companies)
+ city.emitters = []
+ city.warningSources = []
+ for (const c of companies) {
+ const size = sizeForValue(metricFor(c).value, domain)
+ c.height = size.height
+ c.house = size.house
+ const shade = 0.88 + rng() * 0.22
+ const tint = city.palettes.get(c.mask).clone().multiplyScalar(shade)
+ const w = size.house ? 1.35 : 1.5 + rng() * 0.14
+ const d = size.house ? 1.45 : 1.4 + rng() * 0.15
+ c.width = w
+ c.depth = d
+ bodies.push({
+ x: c.x,
+ y: c.height / 2 + 0.13,
+ z: c.z,
+ w,
+ h: c.height,
+ d,
+ color: tint,
+ shade,
+ company: c.id,
+ })
+ c.roofTop = c.height + 0.13
+ const facade = facadeWindows(c, size, w, d),
+ emitters = windowEmitters(c, facade),
+ emitting = new Map(emitters.map((e) => [e.index, e]))
+ city.emitters.push(...emitters)
+ const opening = (f, width, height, color, out = 0.012) => ({
+ x: f.x + f.nx * out,
+ y: f.y,
+ z: f.z + f.nz * out,
+ w: width,
+ h: height,
+ d: 1,
+ ry: f.nx ? Math.PI / 2 : 0,
+ color,
+ company: c.id,
+ })
+ for (const f of facade) {
+ const source = emitting.get(f.index)
+ if (source) {
+ glows.push({
+ ...opening(
+ f,
+ 0.23,
+ 0.32,
+ source.critical ? '#ffad37' : '#555956',
+ 0.029,
+ ),
+ source,
+ })
+ } else
+ windows.push(
+ opening(f, 0.25, 0.35, rng() > 0.75 ? '#f7e6b2' : '#d9e6e8'),
+ )
+ }
+ if (c.status === 'warning')
+ city.warningSources.push({
+ x: c.x,
+ y: c.height + 1.65,
+ z: c.z,
+ company: c.id,
+ phase: rng() * 5,
+ })
+ }
+ const box = new THREE.BoxGeometry(1, 1, 1)
+ city.body = instances(
+ box,
+ buildingMaterial(box, bodies.length),
+ bodies,
+ city.buildings,
+ )
+ const windowPlane = new THREE.PlaneGeometry(1, 1)
+ city.windows = instances(
+ windowPlane,
+ material('#ffffff', { roughness: 0.6 }),
+ windows,
+ city.buildings,
+ )
+ updateWindowLighting(city)
+ city.windowGlow = instances(
+ windowPlane,
+ new THREE.MeshBasicMaterial({ color: '#ffffff', toneMapped: false }),
+ glows,
+ city.buildings,
+ )
+ const criticalBlinks = new Map(
+ companies
+ .filter((c) => c.status === 'critical')
+ .map((c) => [c.id, createCriticalBlink(c.id)]),
+ )
+ city.criticalSurfaces = [city.body].map((mesh) => ({
+ mesh,
+ items: mesh.userData.items.flatMap((item, index) =>
+ criticalBlinks.has(item.company)
+ ? [
+ {
+ index,
+ color: item.color.clone(),
+ blink: criticalBlinks.get(item.company),
+ },
+ ]
+ : [],
+ ),
+ }))
+}
diff --git a/packages/react-charts/src/city/three/effects.js b/packages/react-charts/src/city/three/effects.js
new file mode 100644
index 00000000..3abc2490
--- /dev/null
+++ b/packages/react-charts/src/city/three/effects.js
@@ -0,0 +1,113 @@
+import * as THREE from 'three'
+import { WindowPlumes } from '../engine/plume-renderer.js'
+import { instances, dummy, rgb } from './primitives.js'
+
+const windowLight = new THREE.Color('#ffe0a3')
+
+// Status rendering is independent from terrain, traffic and camera scheduling.
+export function buildEffects(city) {
+ city.effectsKey = null
+ city.plumes = new WindowPlumes(city.emitters, city.buildings)
+ const canvas = document.createElement('canvas')
+ canvas.width = 128
+ canvas.height = 128
+ const ctx = canvas.getContext('2d')
+ ctx.beginPath()
+ ctx.moveTo(64, 13)
+ ctx.lineTo(115, 110)
+ ctx.lineTo(13, 110)
+ ctx.closePath()
+ ctx.lineJoin = 'round'
+ ctx.fillStyle = '#ffd600'
+ ctx.fill()
+ ctx.strokeStyle = '#ffffff'
+ ctx.lineWidth = 18
+ ctx.stroke()
+ ctx.strokeStyle = '#111820'
+ ctx.lineWidth = 10
+ ctx.stroke()
+ ctx.fillStyle = '#111820'
+ ctx.fillRect(59, 45, 10, 34)
+ ctx.fillRect(59, 88, 10, 10)
+ const tex = new THREE.CanvasTexture(canvas)
+ city.warningTexture?.dispose()
+ city.warningTexture = tex
+ tex.colorSpace = THREE.SRGBColorSpace
+ const warningGeo = new THREE.PlaneGeometry(1, 1)
+ city.warning = instances(
+ warningGeo,
+ // Unlit and untone-mapped: the yellow/white/black icon keeps its colors.
+ new THREE.MeshBasicMaterial({
+ map: tex,
+ transparent: true,
+ side: THREE.DoubleSide,
+ depthWrite: false,
+ toneMapped: false,
+ }),
+ city.warningSources.map((f) => ({
+ x: f.x,
+ y: f.y,
+ z: f.z,
+ w: 1.6,
+ h: 1.6,
+ d: 1,
+ company: f.company,
+ })),
+ city.buildings,
+ )
+ city.warning.renderOrder = 4
+ city.updateEffects(0)
+}
+
+export function updateEffects(city, t) {
+ if (!city.plumes) return
+ const key = `${city.windowLight}:${city.effects}:${city.effects ? t : 0}:${city.effects ? city.cameraRevision : 0}`
+ if (city.effectsKey === key) return
+ city.effectsKey = key
+ // Separate irregular timing per company; all surfaces of one building agree.
+ for (const { mesh, items } of city.criticalSurfaces) {
+ const blink = mesh.geometry.getAttribute('criticalBlink')
+ let changed = false
+ for (const item of items) {
+ const showRed = city.effects && (city.reduceMotion || item.blink(t))
+ if (item.lastRed === showRed) continue
+ item.lastRed = showRed
+ blink.setX(item.index, showRed ? 1 : 0)
+ changed = true
+ }
+ if (changed) blink.needsUpdate = true
+ }
+ city.plumes.update(t, city.effects, city.active)
+ city.warning.visible = city.effects
+ if (city.effects) {
+ for (let i = 0; i < city.warningSources.length; i++) {
+ const source = city.warningSources[i]
+ dummy.position.set(
+ source.x,
+ source.y + Math.sin(t * 1.5 + source.phase) * 0.08,
+ source.z,
+ )
+ dummy.quaternion.copy(city.camera.quaternion)
+ dummy.scale.setScalar(city.active.has(source.company) ? 1.6 : 0)
+ dummy.updateMatrix()
+ city.warning.setMatrixAt(i, dummy.matrix)
+ }
+ city.warning.instanceMatrix.needsUpdate = true
+ }
+ city.windowGlow.userData.items.forEach((item, i) => {
+ const source = item.source
+ if (source.critical && city.effects) {
+ const flicker =
+ 0.86 +
+ 0.12 * Math.sin(t * 13 + source.seed) +
+ 0.04 * Math.sin(t * 23 + source.seed * 2)
+ rgb.setRGB(1, 0.34 * flicker, 0.035)
+ } else
+ rgb
+ .set(city.effects ? '#555956' : '#d9e6e8')
+ .lerp(windowLight, city.windowLight ?? 0)
+ city.windowGlow.setColorAt(i, rgb)
+ })
+ if (city.windowGlow.instanceColor)
+ city.windowGlow.instanceColor.needsUpdate = true
+}
diff --git a/packages/react-charts/src/city/three/lighting.js b/packages/react-charts/src/city/three/lighting.js
new file mode 100644
index 00000000..f61109a3
--- /dev/null
+++ b/packages/react-charts/src/city/three/lighting.js
@@ -0,0 +1,164 @@
+import * as THREE from 'three'
+
+const periods = {
+ day: {
+ background: '#e9ede4',
+ sky: '#fff8e9',
+ ground: '#8b9d8b',
+ ambient: 2.5,
+ sun: '#fff6df',
+ sunlight: 3.1,
+ position: [-60, 100, 60],
+ fill: '#d8eaff',
+ fillLight: 0.75,
+ },
+ twilight: {
+ background: '#b99086',
+ sky: '#ffc58e',
+ ground: '#655968',
+ ambient: 1.25,
+ sun: '#ffae64',
+ sunlight: 2.2,
+ position: [-100, 25, 60],
+ fill: '#a4b8ef',
+ fillLight: 0.45,
+ },
+ night: {
+ background: '#141e35',
+ sky: '#9ab6ed',
+ ground: '#252d45',
+ ambient: 0.65,
+ sun: '#a8c7ff',
+ sunlight: 0.45,
+ position: [-60, 100, 60],
+ fill: '#779bea',
+ fillLight: 0.3,
+ },
+}
+
+export const LIGHTING_TRANSITION_MS = 1800
+const windowTint = new THREE.Color('#ffe0a3')
+const profiles = Object.fromEntries(
+ Object.entries(periods).map(([name, settings]) => [
+ name,
+ {
+ ...settings,
+ ...Object.fromEntries(
+ ['background', 'sky', 'ground', 'sun', 'fill'].map((key) => [
+ key,
+ new THREE.Color(settings[key]),
+ ]),
+ ),
+ position: new THREE.Vector3(...settings.position),
+ windowLight: name === 'day' ? 0 : 1,
+ },
+ ]),
+)
+
+// Date#getHours uses the browser's local clock and timezone.
+export function lightingPeriod(date = new Date(), life = true, mode = 'auto') {
+ if (!life) return 'day'
+ if (mode !== 'auto') return mode
+ const hour = date.getHours()
+ if (hour < 6 || hour >= 19) return 'night'
+ if (hour < 8 || hour >= 17) return 'twilight'
+ return 'day'
+}
+
+export function buildLighting(city) {
+ city.lights = {
+ hemisphere: new THREE.HemisphereLight(),
+ sun: new THREE.DirectionalLight(),
+ fill: new THREE.DirectionalLight(),
+ }
+ city.lights.fill.position.set(80, 40, -100)
+ city.scene.add(...Object.values(city.lights))
+ updateLighting(city)
+}
+
+export function updateWindowLighting(city) {
+ if (!city.windows) return
+ const light = city.windowLight ?? 0
+ city.windows.material.color.set('#ffffff').lerp(windowTint, light)
+ city.windows.material.emissive.set('#ffd28a')
+ city.windows.material.emissiveIntensity = light * 1.15
+}
+
+function captureLighting(city) {
+ const { hemisphere, sun, fill } = city.lights
+ return {
+ background: city.scene.background.clone(),
+ sky: hemisphere.color.clone(),
+ ground: hemisphere.groundColor.clone(),
+ ambient: hemisphere.intensity,
+ sun: sun.color.clone(),
+ sunlight: sun.intensity,
+ position: sun.position.clone(),
+ fill: fill.color.clone(),
+ fillLight: fill.intensity,
+ windowLight: city.windowLight,
+ }
+}
+
+function applyLighting(city, from, to, progress) {
+ const { hemisphere, sun, fill } = city.lights
+ city.scene.background.lerpColors(from.background, to.background, progress)
+ hemisphere.color.lerpColors(from.sky, to.sky, progress)
+ hemisphere.groundColor.lerpColors(from.ground, to.ground, progress)
+ hemisphere.intensity = THREE.MathUtils.lerp(
+ from.ambient,
+ to.ambient,
+ progress,
+ )
+ sun.color.lerpColors(from.sun, to.sun, progress)
+ sun.intensity = THREE.MathUtils.lerp(from.sunlight, to.sunlight, progress)
+ sun.position.lerpVectors(from.position, to.position, progress)
+ fill.color.lerpColors(from.fill, to.fill, progress)
+ fill.intensity = THREE.MathUtils.lerp(from.fillLight, to.fillLight, progress)
+ city.windowLight = THREE.MathUtils.lerp(
+ from.windowLight,
+ to.windowLight,
+ progress,
+ )
+ updateWindowLighting(city)
+}
+
+// Use the frame clock, independently of traffic/effects and reduced motion.
+// A color fade does not move the camera or the city.
+export function advanceLighting(city, now) {
+ const transition = city.lightingTransition
+ if (!transition) return false
+ const progress = THREE.MathUtils.clamp(
+ (now - transition.start) / LIGHTING_TRANSITION_MS,
+ 0,
+ 1,
+ )
+ const eased = progress * progress * (3 - 2 * progress)
+ applyLighting(city, transition.from, transition.to, eased)
+ if (progress === 1) city.lightingTransition = null
+ return true
+}
+
+export function updateLighting(
+ city,
+ date = new Date(),
+ now = performance.now(),
+) {
+ const period = lightingPeriod(date, city.life, city.lightingMode)
+ if (city.lightingPeriod === period) return false
+ const target = profiles[period]
+ if (city.lightingPeriod === undefined) {
+ // The first frame already starts at the correct local time.
+ applyLighting(city, target, target, 1)
+ } else {
+ // Retarget from the current blend if the user changes modes mid-fade.
+ advanceLighting(city, now)
+ city.lightingTransition = {
+ from: captureLighting(city),
+ to: target,
+ start: now,
+ }
+ }
+ city.lightingPeriod = period
+ return true
+}
diff --git a/packages/react-charts/src/city/three/primitives.js b/packages/react-charts/src/city/three/primitives.js
new file mode 100644
index 00000000..1752e1d1
--- /dev/null
+++ b/packages/react-charts/src/city/three/primitives.js
@@ -0,0 +1,33 @@
+import * as THREE from 'three'
+
+export const dummy = new THREE.Object3D()
+export const rgb = new THREE.Color()
+export const STEP = 2.8
+
+export function material(color, extra = {}) {
+ return new THREE.MeshStandardMaterial({
+ color,
+ roughness: 0.85,
+ flatShading: true,
+ ...extra,
+ })
+}
+export function instances(geometry, mat, items, scene) {
+ const mesh = new THREE.InstancedMesh(geometry, mat, Math.max(1, items.length))
+ mesh.count = items.length
+ mesh.userData.items = items
+ for (let i = 0; i < items.length; i++) {
+ const it = items[i]
+ dummy.position.set(it.x, it.y, it.z)
+ dummy.rotation.set(0, it.ry ?? 0, 0)
+ dummy.scale.set(it.w ?? 1, it.h ?? 1, it.d ?? 1)
+ dummy.updateMatrix()
+ mesh.setMatrixAt(i, dummy.matrix)
+ if (it.color) mesh.setColorAt(i, rgb.set(it.color))
+ }
+ mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
+ mesh.castShadow = false
+ mesh.receiveShadow = false
+ scene.add(mesh)
+ return mesh
+}
diff --git a/packages/react-charts/src/city/three/snow-cover.js b/packages/react-charts/src/city/three/snow-cover.js
new file mode 100644
index 00000000..48ef30c2
--- /dev/null
+++ b/packages/react-charts/src/city/three/snow-cover.js
@@ -0,0 +1,95 @@
+import * as THREE from 'three'
+import { random } from '../engine/model.js'
+import { instances, material, STEP } from './primitives.js'
+
+// Soft, irregular edges keep the original roof/ground visible between patches.
+function patchTexture() {
+ const size = 64
+ const pixels = new Uint8Array(size * size * 4)
+ for (let y = 0; y < size; y++)
+ for (let x = 0; x < size; x++) {
+ const px = ((x + 0.5) / size) * 2 - 1
+ const py = ((y + 0.5) / size) * 2 - 1
+ const angle = Math.atan2(py, px)
+ const edge =
+ 0.76 + Math.sin(angle * 3 + 0.7) * 0.12 + Math.cos(angle * 7) * 0.07
+ const alpha = THREE.MathUtils.clamp((edge - Math.hypot(px, py)) * 9, 0, 1)
+ pixels.set([255, 255, 255, Math.round(alpha * 255)], (y * size + x) * 4)
+ }
+ const texture = new THREE.DataTexture(pixels, size, size)
+ texture.magFilter = THREE.LinearFilter
+ texture.needsUpdate = true
+ return texture
+}
+
+export class SnowCover extends THREE.Group {
+ constructor() {
+ super()
+ this.geometry = new THREE.PlaneGeometry(1, 1)
+ this.geometry.rotateX(-Math.PI / 2)
+ this.material = material('#f4f8ff', {
+ map: patchTexture(),
+ transparent: true,
+ opacity: 0,
+ depthWrite: false,
+ alphaTest: 0.01,
+ })
+ this.mesh = instances(this.geometry, this.material, [], this)
+ this.amount = 0
+ this.visible = false
+ }
+
+ setLayout(layout) {
+ const rng = random(28419)
+ const patches = []
+ const rb = layout.roadBounds
+ for (let z = rb.z1; z <= rb.z2; z++)
+ for (let x = rb.x1; x <= rb.x2; x++) {
+ if (rng() < 0.25) continue
+ patches.push({
+ x: x * STEP + (rng() - 0.5) * 0.7,
+ y: 0.068,
+ z: z * STEP + (rng() - 0.5) * 0.7,
+ w: 0.8 + rng() * 1.3,
+ d: 0.7 + rng() * 1.1,
+ ry: rng() * Math.PI * 2,
+ })
+ }
+ this.groundCount = patches.length
+ for (const c of layout.companies) {
+ if (![c.x, c.z, c.roofTop, c.width, c.depth].every(Number.isFinite))
+ continue
+ const w = c.width * (0.5 + rng() * 0.25)
+ const d = c.depth * (0.5 + rng() * 0.25)
+ patches.push({
+ x: c.x + (rng() - 0.5) * (c.width - w) * 0.8,
+ y: c.roofTop + 0.018,
+ z: c.z + (rng() - 0.5) * (c.depth - d) * 0.8,
+ w,
+ d,
+ })
+ }
+ // Reuse texture, material and geometry; release the old instance buffer.
+ this.remove(this.mesh)
+ this.mesh.dispose()
+ this.mesh = instances(this.geometry, this.material, patches, this)
+ }
+
+ update(time, enabled) {
+ this.visible = enabled
+ if (!enabled) {
+ this.amount = 0
+ this.previousTime = undefined
+ } else if (this.previousTime === undefined) {
+ this.amount = 0.2
+ this.previousTime = time
+ } else {
+ this.amount = Math.min(
+ 1,
+ this.amount + Math.max(0, time - this.previousTime) / 30,
+ )
+ this.previousTime = time
+ }
+ this.material.opacity = this.amount * 0.7
+ }
+}
diff --git a/packages/react-charts/src/city/three/terrain.js b/packages/react-charts/src/city/three/terrain.js
new file mode 100644
index 00000000..56443970
--- /dev/null
+++ b/packages/react-charts/src/city/three/terrain.js
@@ -0,0 +1,200 @@
+import * as THREE from 'three'
+import { ROAD } from '../engine/traffic.js'
+import { ROAD_EVERY } from '../engine/model.js'
+import { greenLots } from '../engine/landscape.js'
+import { instances, material, STEP } from './primitives.js'
+
+// Roads, markings and sidewalks are two-triangle surfaces. Trees keep volume.
+export function buildTerrain(city) {
+ const plane = new THREE.PlaneGeometry(1, 1)
+ plane.rotateX(-Math.PI / 2)
+ const asphalt = [],
+ roadLines = [],
+ timber = [],
+ leaves = []
+ const rb = city.layout.roadBounds
+ for (let z = rb.z1; z <= rb.z2; z++)
+ for (let x = rb.x1; x <= rb.x2; x++) {
+ const road = x % ROAD_EVERY === 0 || z % ROAD_EVERY === 0
+ if (road) {
+ // One flat strip per complete road replaces every volumetric tile.
+ if (x === rb.x1 && z % ROAD_EVERY === 0)
+ asphalt.push({
+ x: ((rb.x1 + rb.x2) * STEP) / 2,
+ y: 0.015,
+ z: z * STEP,
+ w: (rb.x2 - rb.x1 + 1) * STEP,
+ d: ROAD.width,
+ color: '#7c8989',
+ })
+ if (z === rb.z1 && x % ROAD_EVERY === 0)
+ asphalt.push({
+ x: x * STEP,
+ y: 0.016,
+ z: ((rb.z1 + rb.z2) * STEP) / 2,
+ w: ROAD.width,
+ d: (rb.z2 - rb.z1 + 1) * STEP,
+ color: '#7c8989',
+ })
+ const nearJunction = (v) =>
+ Math.min(
+ ((v % ROAD_EVERY) + ROAD_EVERY) % ROAD_EVERY,
+ ROAD_EVERY - (((v % ROAD_EVERY) + ROAD_EVERY) % ROAD_EVERY),
+ ) <= 1
+ if (x % ROAD_EVERY !== 0 && z % ROAD_EVERY === 0 && !nearJunction(x))
+ roadLines.push({
+ x: x * STEP,
+ y: 0.052,
+ z: z * STEP,
+ w: 0.7,
+ h: 0.012,
+ d: 0.035,
+ color: '#e4e5da',
+ })
+ if (x % ROAD_EVERY === 0 && z % ROAD_EVERY !== 0 && !nearJunction(z))
+ roadLines.push({
+ x: x * STEP,
+ y: 0.052,
+ z: z * STEP,
+ w: 0.035,
+ h: 0.012,
+ d: 0.7,
+ color: '#e4e5da',
+ })
+ if (x % ROAD_EVERY === 0 && z % ROAD_EVERY === 0) {
+ for (const sign of [-1, 1]) {
+ for (let i = 0; i < 3; i++) {
+ const q = (i - 1) * 0.58
+ if (z + sign >= rb.z1 && z + sign <= rb.z2)
+ roadLines.push({
+ x: x * STEP + q,
+ y: 0.055,
+ z: z * STEP + sign * ROAD.crosswalk,
+ w: 0.3,
+ h: 0.012,
+ d: 0.5,
+ color: '#f1efe3',
+ })
+ if (x + sign >= rb.x1 && x + sign <= rb.x2)
+ roadLines.push({
+ x: x * STEP + sign * ROAD.crosswalk,
+ y: 0.055,
+ z: z * STEP + q,
+ w: 0.5,
+ h: 0.012,
+ d: 0.3,
+ color: '#f1efe3',
+ })
+ }
+ if (x + sign >= rb.x1 && x + sign <= rb.x2)
+ roadLines.push({
+ x: x * STEP + sign * ROAD.stop,
+ y: 0.055,
+ z: z * STEP - sign * ROAD.lane,
+ w: 0.07,
+ h: 0.012,
+ d: 0.82,
+ color: '#f1efe3',
+ })
+ if (z + sign >= rb.z1 && z + sign <= rb.z2)
+ roadLines.push({
+ x: x * STEP + sign * ROAD.lane,
+ y: 0.055,
+ z: z * STEP + sign * ROAD.stop,
+ w: 0.82,
+ h: 0.012,
+ d: 0.07,
+ color: '#f1efe3',
+ })
+ }
+ }
+ }
+ }
+ for (const lot of greenLots(city.layout)) {
+ const x = lot.col * STEP,
+ z = lot.row * STEP
+ for (const tree of lot.trees) {
+ const tx = x + tree.dx,
+ tz = z + tree.dz,
+ s = tree.scale
+ timber.push({
+ x: tx,
+ y: 0.41,
+ z: tz,
+ w: 0.12,
+ h: 0.65,
+ d: 0.12,
+ color: '#947c5f',
+ })
+ leaves.push({
+ x: tx,
+ y: 0.96,
+ z: tz,
+ w: s,
+ h: s * 1.4,
+ d: s,
+ color: tree.color,
+ })
+ }
+ // One low block per bench, roughly knee-high to the pedestrians and
+ // one third of a small house's width; shares the trunks' instance batch.
+ for (const bench of lot.benches) {
+ timber.push({
+ x: x + bench.dx,
+ y: 0.075,
+ z: z + bench.dz,
+ w: 0.48,
+ h: 0.15,
+ d: 0.16,
+ ry: bench.rotation,
+ color: '#987653',
+ })
+ }
+ }
+ const box = new THREE.BoxGeometry(1, 1, 1)
+ const sidewalks = asphalt.map((strip) => ({
+ ...strip,
+ y: 0.004,
+ w: strip.w === ROAD.width ? STEP : strip.w,
+ d: strip.d === ROAD.width ? STEP : strip.d,
+ color: '#c5ccd0',
+ }))
+ // A single lawn plane fills the city. Building lots are beige planes and
+ // sidewalks are gray planes, so each use remains readable without boxes.
+ const lots = [
+ {
+ x: ((rb.x1 + rb.x2) * STEP) / 2,
+ y: -0.005,
+ z: ((rb.z1 + rb.z2) * STEP) / 2,
+ w: (rb.x2 - rb.x1 + 1) * STEP,
+ d: (rb.z2 - rb.z1 + 1) * STEP,
+ color: '#b2c98c',
+ },
+ ...city.layout.companies.map((company) => ({
+ x: company.x,
+ y: 0.002,
+ z: company.z,
+ w: STEP - 0.12,
+ d: STEP - 0.12,
+ color: '#eee9df',
+ })),
+ ]
+ instances(plane, material('#ffffff'), lots, city.terrain)
+ instances(plane, material('#ffffff'), sidewalks, city.terrain)
+ instances(plane, material('#ffffff'), asphalt, city.terrain)
+ instances(plane, material('#ffffff'), roadLines, city.terrain)
+ instances(box, material('#ffffff'), timber, city.terrain)
+ instances(
+ new THREE.OctahedronGeometry(1, 0),
+ material('#ffffff'),
+ leaves,
+ city.terrain,
+ )
+ const ground = new THREE.Mesh(
+ new THREE.PlaneGeometry(1600, 1600),
+ material('#e9ede4'),
+ )
+ ground.rotation.x = -Math.PI / 2
+ ground.position.y = -0.01
+ city.terrain.add(ground)
+}
diff --git a/packages/react-charts/src/city/three/traffic.js b/packages/react-charts/src/city/three/traffic.js
new file mode 100644
index 00000000..bf2cee0a
--- /dev/null
+++ b/packages/react-charts/src/city/three/traffic.js
@@ -0,0 +1,204 @@
+import * as THREE from 'three'
+import { TrafficSimulation } from '../engine/traffic.js'
+import { instances, material, dummy, rgb, STEP } from './primitives.js'
+
+// Rendering stays separate from the traffic simulation and its crossing rules.
+export function buildTraffic(city) {
+ city.traffic = new THREE.Group()
+ city.scene.add(city.traffic)
+ city.traffic.visible = city.life
+ city.simulation = new TrafficSimulation(city.layout.roadBounds)
+ const sim = city.simulation,
+ colors = ['#faf8eb', '#d7b86c', '#829b9b', '#d28a76', '#52777b', '#f2dfab']
+ city.cars = instances(
+ new THREE.BoxGeometry(1, 1, 1),
+ material('#ffffff'),
+ sim.cars.map((c, i) => ({
+ x: c.x,
+ y: 0.28,
+ z: c.z,
+ w: 0.58,
+ h: 0.32,
+ d: 0.95,
+ color: colors[i % colors.length],
+ })),
+ city.traffic,
+ )
+ city.carTops = instances(
+ new THREE.BoxGeometry(1, 1, 1),
+ material('#a6bfc1', { roughness: 0.35 }),
+ sim.cars.map((c) => ({
+ x: c.x,
+ y: 0.51,
+ z: c.z,
+ w: 0.47,
+ h: 0.16,
+ d: 0.44,
+ })),
+ city.traffic,
+ )
+ city.walkers = instances(
+ new THREE.BoxGeometry(0.14, 0.36, 0.12),
+ material('#ffffff'),
+ sim.walkers.map((p, i) => ({
+ x: p.x,
+ y: 0.34,
+ z: p.z,
+ color: ['#bb7a66', '#667c84', '#b49e69', '#f5ead4', '#6d8973'][i % 5],
+ })),
+ city.traffic,
+ )
+ city.heads = instances(
+ new THREE.OctahedronGeometry(0.078, 0),
+ material('#dcc7a9'),
+ sim.walkers.map((p) => ({ x: p.x, y: 0.57, z: p.z })),
+ city.traffic,
+ )
+ const posts = [],
+ housings = [],
+ bulbs = []
+ city.lampItems = []
+ for (const node of sim.intersections) {
+ for (const [axis, sign] of [
+ ['x', 1],
+ ['x', -1],
+ ['z', 1],
+ ['z', -1],
+ ]) {
+ const bounds = city.layout.roadBounds
+ const x = THREE.MathUtils.clamp(
+ node.x + (axis === 'x' ? sign * 1.52 : -sign * 1.52),
+ bounds.x1 * STEP - 1.17,
+ bounds.x2 * STEP + 1.17,
+ ),
+ z = THREE.MathUtils.clamp(
+ node.z + sign * 1.52,
+ bounds.z1 * STEP - 1.17,
+ bounds.z2 * STEP + 1.17,
+ )
+ posts.push({
+ x,
+ y: 0.75,
+ z,
+ w: 0.055,
+ h: 1.4,
+ d: 0.055,
+ color: '#596763',
+ })
+ housings.push({
+ x,
+ y: 1.6,
+ z,
+ w: axis === 'x' ? 0.14 : 0.22,
+ h: 0.54,
+ d: axis === 'x' ? 0.22 : 0.14,
+ color: '#35433f',
+ })
+ for (let k = 0; k < 3; k++) {
+ const item = {
+ x: x + (axis === 'x' ? -sign * 0.085 : 0),
+ y: 1.77 - k * 0.17,
+ z: z + (axis === 'z' ? -sign * 0.085 : 0),
+ w: 0.06,
+ h: 0.06,
+ d: 0.06,
+ color: '#263b32',
+ }
+ bulbs.push(item)
+ city.lampItems.push({
+ node,
+ axis,
+ state: ['red', 'amber', 'green'][k],
+ })
+ }
+ housings.push({
+ x,
+ y: 0.97,
+ z,
+ w: 0.16,
+ h: 0.2,
+ d: 0.16,
+ color: '#35433f',
+ })
+ bulbs.push({
+ x: x + (axis === 'x' ? -sign * 0.09 : 0),
+ y: 0.98,
+ z: z + (axis === 'z' ? -sign * 0.09 : 0),
+ w: 0.045,
+ h: 0.065,
+ d: 0.045,
+ color: '#a3473b',
+ })
+ city.lampItems.push({ node, axis: 'ped', state: 'walk' })
+ }
+ }
+ instances(
+ new THREE.BoxGeometry(1, 1, 1),
+ material('#ffffff'),
+ posts,
+ city.traffic,
+ )
+ instances(
+ new THREE.BoxGeometry(1, 1, 1),
+ material('#ffffff'),
+ housings,
+ city.traffic,
+ )
+ city.signalLamps = instances(
+ new THREE.BoxGeometry(1, 1, 1),
+ new THREE.MeshBasicMaterial({ color: '#ffffff', toneMapped: false }),
+ bulbs,
+ city.traffic,
+ )
+ city.updateTraffic(0)
+}
+export function updateTraffic(city, dt) {
+ if (!city.simulation || (!city.life && dt > 0)) return
+ const sim = city.simulation
+ if (city.life) sim.step(dt)
+ sim.cars.forEach((c, i) => {
+ dummy.position.set(c.x, 0.28, c.z)
+ dummy.rotation.set(0, c.ry, 0)
+ dummy.scale.set(0.58, 0.32, 0.95)
+ dummy.updateMatrix()
+ city.cars.setMatrixAt(i, dummy.matrix)
+ dummy.position.y = 0.51
+ dummy.scale.set(0.47, 0.16, 0.44)
+ dummy.updateMatrix()
+ city.carTops.setMatrixAt(i, dummy.matrix)
+ })
+ for (const m of [city.cars, city.carTops]) {
+ m.instanceMatrix.needsUpdate = true
+ m.frustumCulled = false
+ }
+ sim.walkers.forEach((p, i) => {
+ dummy.position.set(p.x, 0.34, p.z)
+ dummy.rotation.set(0, p.ry, 0)
+ dummy.scale.setScalar(1)
+ dummy.updateMatrix()
+ city.walkers.setMatrixAt(i, dummy.matrix)
+ dummy.position.y = 0.57
+ dummy.updateMatrix()
+ city.heads.setMatrixAt(i, dummy.matrix)
+ })
+ for (const m of [city.walkers, city.heads]) {
+ m.instanceMatrix.needsUpdate = true
+ m.frustumCulled = false
+ }
+ city.lampItems.forEach((lamp, i) => {
+ const on =
+ lamp.axis === 'ped'
+ ? sim.canWalk(lamp.node)
+ : sim.lamp(lamp.node, lamp.axis) === lamp.state
+ const color =
+ lamp.axis === 'ped'
+ ? on
+ ? '#83f5a1'
+ : '#df6351'
+ : on
+ ? { red: '#ff5949', amber: '#ffd56c', green: '#75eda0' }[lamp.state]
+ : '#263a32'
+ city.signalLamps.setColorAt(i, rgb.set(color))
+ })
+ city.signalLamps.instanceColor.needsUpdate = true
+}
diff --git a/packages/react-charts/src/city/three/weather.js b/packages/react-charts/src/city/three/weather.js
new file mode 100644
index 00000000..1051f290
--- /dev/null
+++ b/packages/react-charts/src/city/three/weather.js
@@ -0,0 +1,251 @@
+import * as THREE from 'three'
+import { STEP } from './primitives.js'
+import { random } from '../engine/model.js'
+import { SnowCover } from './snow-cover.js'
+
+const COUNT = 800
+// Keep small examples from receiving a full-size city's rain in less space.
+// The usual 100-company city still reaches the existing 800-drop budget.
+const RAIN_DROPS_PER_CELL = 1.2
+const fraction = (value) => value - Math.floor(value)
+const rainDay = new THREE.Color('#356b91')
+const rainNight = new THREE.Color('#b5d4eb')
+
+function snowTexture() {
+ const size = 32
+ const pixels = new Uint8Array(size * size * 4)
+ for (let y = 0; y < size; y++)
+ for (let x = 0; x < size; x++) {
+ const i = (y * size + x) * 4
+ const radius = Math.hypot(
+ ((x + 0.5) / size) * 2 - 1,
+ ((y + 0.5) / size) * 2 - 1,
+ )
+ // A white center with a blue-gray rim stays legible on bright ground
+ // without turning the entire snowflake dark in the daytime.
+ const core = THREE.MathUtils.clamp((0.7 - radius) * 5, 0, 1)
+ pixels.set(
+ [
+ Math.round(94 + 161 * core),
+ Math.round(116 + 139 * core),
+ Math.round(135 + 120 * core),
+ Math.round(255 * Math.max(0, Math.min(1, (1 - radius) * 6))),
+ ],
+ i,
+ )
+ }
+ const texture = new THREE.DataTexture(pixels, size, size)
+ texture.colorSpace = THREE.SRGBColorSpace
+ texture.magFilter = texture.minFilter = THREE.LinearFilter
+ texture.needsUpdate = true
+ return texture
+}
+
+// Two shared buffers, reused across weather changes and city pagination.
+export class CityWeather extends THREE.Group {
+ constructor({ lightningRandom = Math.random } = {}) {
+ super()
+ this.weatherType = 'none'
+ this.visible = false
+ this.lightningRandom = lightningRandom
+ const rng = random(98173)
+ const clusters = Array.from({ length: 7 }, () => [rng(), rng()])
+ this.flakes = Array.from({ length: COUNT }, (_, i) => {
+ const cluster = clusters[i % clusters.length]
+ const clustered = rng() < 0.55
+ return {
+ x: clustered ? fraction(cluster[0] + (rng() - 0.5) * 0.32) : rng(),
+ z: clustered ? fraction(cluster[1] + (rng() - 0.5) * 0.32) : rng(),
+ phase: rng(),
+ speed: 1.3 + rng() * 2,
+ sway: 0.15 + rng() * 0.65,
+ frequency: 0.4 + rng() * 1.1,
+ angle: rng() * Math.PI * 2,
+ }
+ })
+ // Independent seeds break the diagonal bands produced by index-based
+ // sequences. Keep them stable between frames without allocating particles.
+ const rainRandom = random(61547)
+ this.drops = Array.from({ length: COUNT }, () => ({
+ x: rainRandom(),
+ z: rainRandom(),
+ phase: rainRandom(),
+ speed: 18 + rainRandom() * 12,
+ length: 0.65 + rainRandom() * 1.25,
+ windX: 1.8 + rainRandom() * 1.4,
+ windZ: 0.4 + rainRandom() * 0.8,
+ }))
+ const rainGeometry = new THREE.BufferGeometry()
+ rainGeometry.setAttribute(
+ 'position',
+ new THREE.BufferAttribute(new Float32Array(COUNT * 6), 3).setUsage(
+ THREE.DynamicDrawUsage,
+ ),
+ )
+ const snowGeometry = new THREE.BufferGeometry()
+ snowGeometry.setAttribute(
+ 'position',
+ new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3).setUsage(
+ THREE.DynamicDrawUsage,
+ ),
+ )
+ this.rain = new THREE.LineSegments(
+ rainGeometry,
+ new THREE.LineBasicMaterial({
+ color: '#b5d4eb',
+ transparent: true,
+ opacity: 0.8,
+ depthWrite: false,
+ toneMapped: false,
+ }),
+ )
+ this.snow = new THREE.Points(
+ snowGeometry,
+ new THREE.PointsMaterial({
+ color: '#ffffff',
+ size: 3,
+ sizeAttenuation: false,
+ map: snowTexture(),
+ transparent: true,
+ opacity: 0.95,
+ depthWrite: false,
+ toneMapped: false,
+ }),
+ )
+ this.rain.frustumCulled = this.snow.frustumCulled = false
+ this.cover = new SnowCover()
+ this.lightning = new THREE.DirectionalLight('#dceaff', 0)
+ this.lightning.position.set(-60, 100, 40)
+ this.add(this.rain, this.snow, this.cover, this.lightning)
+ }
+
+ setLayout(layout) {
+ const { x1, x2, z1, z2 } = layout.roadBounds
+ this.bounds = {
+ x: (x1 - 2) * STEP,
+ z: (z1 - 2) * STEP,
+ width: (x2 - x1 + 4) * STEP,
+ depth: (z2 - z1 + 4) * STEP,
+ }
+ this.rainCount = Math.min(
+ COUNT,
+ Math.max(
+ 1,
+ Math.round((x2 - x1 + 4) * (z2 - z1 + 4) * RAIN_DROPS_PER_CELL),
+ ),
+ )
+ this.rain.geometry.setDrawRange(0, this.rainCount * 2)
+ this.height = Math.max(
+ 18,
+ ...layout.companies.map((c) => (c.roofTop ?? c.height ?? 0) + 12),
+ )
+ this.cover.setLayout(layout)
+ }
+
+ updateLightning(time, enabled) {
+ this.lightning.intensity = 0
+ if (!enabled) {
+ this.nextLightning = undefined
+ this.lightningStart = undefined
+ this.lightningStrokes = undefined
+ return
+ }
+ if (this.nextLightning === undefined)
+ this.nextLightning = time + 12 + this.lightningRandom() * 12
+ if (time >= this.nextLightning) {
+ this.lightningStart = time
+ this.nextLightning = time + 18 + this.lightningRandom() * 20
+ const angle = this.lightningRandom() * Math.PI * 2
+ this.lightning.position.set(
+ Math.cos(angle) * 80,
+ 100,
+ Math.sin(angle) * 80,
+ )
+ const count = this.lightningRandom() < 0.35 ? 3 : 2
+ const peak = 3.6 + this.lightningRandom() * 1.2
+ let start = 0
+ this.lightningStrokes = Array.from({ length: count }, (_, index) => {
+ const duration = 0.08 + this.lightningRandom() * 0.06
+ const stroke = {
+ start,
+ duration,
+ peak:
+ index === 0 ? peak : peak * (0.3 + this.lightningRandom() * 0.3),
+ }
+ start += duration + 0.06 + this.lightningRandom() * 0.16
+ return stroke
+ })
+ }
+ if (this.lightningStart === undefined) return
+ const elapsed = time - this.lightningStart
+ // An abrupt discharge, a brief bright hold, then rapid decay. Dark gaps
+ // separate weaker return strokes; the sequence is fixed for this event.
+ for (const stroke of this.lightningStrokes) {
+ const age = elapsed - stroke.start
+ if (age >= 0 && age < stroke.duration) {
+ const decay = Math.max(0, age - 0.02) / (stroke.duration - 0.02)
+ this.lightning.intensity = stroke.peak * Math.exp(-6 * decay)
+ return
+ }
+ }
+ const last = this.lightningStrokes.at(-1)
+ if (elapsed >= last.start + last.duration) {
+ this.lightningStart = undefined
+ this.lightningStrokes = undefined
+ }
+ }
+
+ update(time, type, enabled, background) {
+ this.weatherType = type
+ this.visible = Boolean(enabled && this.bounds && type !== 'none')
+ this.rain.visible = type === 'rain'
+ this.snow.visible = type === 'snow'
+ this.cover.update(time, this.visible && type === 'snow')
+ this.updateLightning(time, this.visible && type === 'rain')
+ if (!this.visible) return
+ const rain = type === 'rain'
+ if (rain) {
+ const luminance = background
+ ? background.r * 0.2126 + background.g * 0.7152 + background.b * 0.0722
+ : 1
+ this.rain.material.color.lerpColors(
+ rainNight,
+ rainDay,
+ THREE.MathUtils.clamp((luminance - 0.04) / 0.5, 0, 1),
+ )
+ }
+ const attribute = (rain ? this.rain : this.snow).geometry.attributes
+ .position
+ const positions = attribute.array
+ const { x, z, width, depth } = this.bounds
+ for (let i = 0; i < (rain ? this.rainCount : COUNT); i++) {
+ const flake = rain ? this.drops[i] : this.flakes[i]
+ const px =
+ x +
+ fraction(flake.x + (time * (rain ? flake.windX : 0.3)) / width) * width
+ const pz =
+ z +
+ fraction(flake.z + (time * (rain ? flake.windZ : 0.15)) / depth) * depth
+ const py =
+ 0.25 +
+ fraction(flake.phase - (time * flake.speed) / this.height) * this.height
+ const offset = i * (rain ? 6 : 3)
+ positions[offset] =
+ px +
+ (rain ? 0 : Math.sin(time * flake.frequency + flake.angle) * flake.sway)
+ positions[offset + 1] = py
+ positions[offset + 2] =
+ pz +
+ (rain
+ ? 0
+ : Math.cos(time * flake.frequency * 0.7 + flake.angle) * flake.sway)
+ if (rain) {
+ const trailTime = flake.length / flake.speed
+ positions[offset + 3] = px - flake.windX * trailTime
+ positions[offset + 4] = py + flake.length
+ positions[offset + 5] = pz - flake.windZ * trailTime
+ }
+ }
+ attribute.needsUpdate = true
+ }
+}
diff --git a/packages/react-charts/src/city/traffic.test.mjs b/packages/react-charts/src/city/traffic.test.mjs
new file mode 100644
index 00000000..e93bee6c
--- /dev/null
+++ b/packages/react-charts/src/city/traffic.test.mjs
@@ -0,0 +1,109 @@
+import { test } from 'vitest'
+import assert from 'node:assert/strict'
+import {
+ ROAD,
+ TrafficSimulation,
+ signalState,
+ roadTileParts,
+} from './engine/traffic.js'
+
+const pointInCar = (car, x, z, padding = 0) => {
+ const dx = x - car.x,
+ dz = z - car.z,
+ c = Math.cos(car.ry),
+ s = Math.sin(car.ry)
+ return (
+ Math.abs(dx * c - dz * s) < ROAD.carWidth / 2 + padding &&
+ Math.abs(dx * s + dz * c) < ROAD.carLength / 2 + padding
+ )
+}
+function carsOverlap(a, b) {
+ const axes = [
+ [Math.cos(a.ry), -Math.sin(a.ry)],
+ [Math.sin(a.ry), Math.cos(a.ry)],
+ [Math.cos(b.ry), -Math.sin(b.ry)],
+ [Math.sin(b.ry), Math.cos(b.ry)],
+ ]
+ const radius = (car, axis) =>
+ (Math.abs(axis[0] * Math.cos(car.ry) - axis[1] * Math.sin(car.ry)) *
+ ROAD.carWidth) /
+ 2 +
+ (Math.abs(axis[0] * Math.sin(car.ry) + axis[1] * Math.cos(car.ry)) *
+ ROAD.carLength) /
+ 2
+ return axes.every(
+ (axis) =>
+ Math.abs((b.x - a.x) * axis[0] + (b.z - a.z) * axis[1]) <
+ radius(a, axis) + radius(b, axis) - 1e-5,
+ )
+}
+test('intersection pavement fully connects all four streets', () => {
+ const parts = roadTileParts(0, 0)
+ const covered = (x, z) =>
+ parts.some(
+ (p) =>
+ Math.abs(x - p.dx) <= p.w / 2 + 1e-8 &&
+ Math.abs(z - p.dz) <= p.d / 2 + 1e-8,
+ )
+ for (let s = -ROAD.width / 2; s <= ROAD.width / 2; s += 0.05) {
+ assert.ok(covered(ROAD.step / 2, s))
+ assert.ok(covered(-ROAD.step / 2, s))
+ assert.ok(covered(s, ROAD.step / 2))
+ assert.ok(covered(s, -ROAD.step / 2))
+ }
+ assert.equal(roadTileParts(1, 1).length, 0)
+})
+test('traffic lights alternate and reserve an exclusive pedestrian interval', () => {
+ for (let t = 0; t < 84; t += 0.1) {
+ const p = signalState(t)
+ assert.ok(!(p.x === 'green' && p.z === 'green'))
+ if (p.walk) {
+ assert.equal(p.x, 'red')
+ assert.equal(p.z, 'red')
+ }
+ }
+})
+test('traffic maintains separation and respects pedestrians over multiple cycles', () => {
+ for (const bounds of [
+ { x1: -4, x2: 4, z1: -4, z2: 4 },
+ { x1: -12, x2: 12, z1: -8, z2: 8 },
+ { x1: -28, x2: -12, z1: 4, z2: 20 },
+ ]) {
+ const sim = new TrafficSimulation(bounds)
+ let crossings = 0,
+ stops = 0,
+ lastDistances = []
+ for (let frame = 0; frame < 5000; frame++) {
+ sim.step(0.04)
+ if (frame === 2900) lastDistances = sim.cars.map((c) => c.distance)
+ for (const node of sim.intersections) {
+ assert.ok(
+ !(node.owner !== null && node.pedestrians > 0),
+ 'junction shared by pedestrians and vehicle',
+ )
+ }
+ for (let i = 0; i < sim.cars.length; i++) {
+ const a = sim.cars[i]
+ if (a.speed < 0.01 && frame > 100) stops++
+ for (let j = i + 1; j < sim.cars.length; j++) {
+ const b = sim.cars[j]
+ if (Math.hypot(a.x - b.x, a.z - b.z) < 1.3)
+ assert.ok(!carsOverlap(a, b), `vehicles overlap at frame ${frame}`)
+ }
+ for (const p of sim.walkers)
+ if (Math.hypot(a.x - p.x, a.z - p.z) < 1)
+ assert.ok(
+ !pointInCar(a, p.x, p.z, 0.09),
+ `pedestrian collision at frame ${frame}`,
+ )
+ }
+ crossings += sim.walkers.filter((w) => w.state === 'crossing').length
+ }
+ assert.ok(crossings > 0)
+ assert.ok(stops > 0)
+ assert.ok(
+ sim.cars.every((c, i) => c.distance - lastDistances[i] > 1),
+ 'cars should keep moving across later signal cycles',
+ )
+ }
+})
diff --git a/packages/react-charts/src/city/types.ts b/packages/react-charts/src/city/types.ts
new file mode 100644
index 00000000..a82a5229
--- /dev/null
+++ b/packages/react-charts/src/city/types.ts
@@ -0,0 +1,197 @@
+import type {
+ DomChartDefinition as ChartDefinition,
+ ChartDefinitionOptions,
+ ChartFocusStrategy,
+ ChartMark,
+ ChartRenderer,
+ ChartSpec,
+} from '@tanstack/charts'
+
+export interface CityDatum {
+ id: string | number
+ name: string
+ metric: { label: string; value: number }
+ categories?: readonly { label: string; value: string }[]
+ status: 'healthy' | 'warning' | 'critical'
+ /** Last record update, ISO 8601 with timezone. */
+ updatedAt?: string
+ /** When the current status began, ISO 8601 with timezone. */
+ statusChangedAt?: string
+ mask?: 1 | 2 | 3 | 4 | 5 | 6 | 7
+}
+export type CityView = 'iso' | 'top'
+export type CityWeather = 'none' | 'rain' | 'snow'
+export type CityLighting = 'auto' | 'day' | 'twilight' | 'night'
+export interface CityLayout {
+ companies: (T & {
+ x: number
+ z: number
+ col: number
+ row: number
+ mask: number
+ })[]
+ roadBounds: { x1: number; x2: number; z1: number; z2: number }
+ bounds: Record
+ anchors: Record
+ /** Shared metric extent; omit to derive it from the layout's companies. */
+ metricDomain?: [number, number]
+ center: [number, number]
+}
+export type CityDefinitionOptions =
+ ChartDefinitionOptions &
+ Omit, 'marks'> & { layout?: CityLayout }
+export declare function defineCityChart(
+ data: readonly T[],
+ options?: CityDefinitionOptions,
+): ChartDefinition
+export declare function cityMark(
+ data: readonly T[],
+ options?: { id?: string; layout?: CityLayout },
+): ChartMark
+export declare const cityFocus: {
+ resolve(
+ points: readonly ChartPoint[],
+ context: Parameters<
+ ChartFocusStrategy['resolve']
+ >[1],
+ ): readonly ChartPoint[]
+ group(
+ points: readonly ChartPoint[],
+ context: Parameters['group']>[1],
+ ): readonly ChartPoint[]
+ navigation(
+ points: readonly ChartPoint[],
+ ): readonly ChartPoint[]
+}
+export interface CityEngine {
+ setActivity(options: { life?: boolean; effects?: boolean }): void
+ setAutoRotate(enabled: boolean): void
+ setLightingMode(mode: CityLighting): void
+ setWeather(weather: CityWeather): void
+ view(view: CityView, immediate?: boolean): void
+ zoom(factor: number): void
+ select(company: T | null): void
+ focus(company: T, options?: { maxZoom?: boolean }): void
+ companies: T[]
+ companiesById: Map
+ layout: CityLayout
+ dispose(): void
+}
+export interface CityRendererOptions {
+ autoRotate?: boolean
+ view?: CityView
+ life?: boolean
+ effects?: boolean
+ onMount?: (city: CityEngine) => void
+ onData?: (city: CityEngine, options: { resetView: boolean }) => void
+ onDestroy?: (city: CityEngine) => void
+ onError?: (error: Error) => void
+ hover?: (company: T | null, context?: { event?: PointerEvent }) => void
+ projection?: (city: CityEngine) => void
+ frame?: (city: CityEngine) => void
+}
+export declare function createCityRenderer(
+ options?: CityRendererOptions,
+): ChartRenderer
+
+import type { JSX, ReactNode, Ref } from 'react'
+import type { ChartPoint } from '@tanstack/charts'
+
+export interface CityStats {
+ drawCalls: number
+ triangles: number
+ chartRenderer: 'atlas-city-3d'
+ chartPoints: number
+ lightingMode?: CityLighting
+ lightingPeriod?: 'day' | 'twilight' | 'night'
+ weather?: CityWeather
+ weatherVisible: boolean
+}
+export interface CityChartHandle {
+ zoom(factor: number): void
+ view(view: CityView): void
+ select(datum: T, options?: { focus?: boolean; maxZoom?: boolean }): void
+ deselect(): void
+ cancel(): void
+ capture(): void
+ play(direction: number): Promise | undefined
+ scrollIntoView(): void
+ stats(): CityStats | undefined
+}
+export interface CityMapProps {
+ data: readonly T[]
+ ref?: Ref>
+ className?: string
+ /** "default" applies default CSS variables; null uses only the host theme. */
+ theme?: 'default' | null
+ title?: string
+ ariaLabel?: string
+ busy?: boolean
+ view?: CityView
+ onViewChange?: (view: CityView) => void
+ life?: boolean
+ onLifeChange?: (enabled: boolean) => void
+ effects?: boolean
+ onEffectsChange?: (enabled: boolean) => void
+ autoRotate?: boolean
+ onAutoRotateChange?: (enabled: boolean) => void
+ showDistrictLabels?: boolean
+ onDistrictLabelsChange?: (enabled: boolean) => void
+ lightingMode?: CityLighting
+ weather?: CityWeather
+ showControls?: boolean
+ showEnvironmentControls?: boolean
+ showSearch?: boolean
+ showDetails?: boolean
+ showMonitor?: boolean
+ showCompass?: boolean
+ showInstructions?: boolean
+ onSelect?: (datum: T | null) => void
+ onFocusChange?: (point: ChartPoint | null) => void
+ children?: ReactNode
+}
+export declare function CityMap(
+ props: CityMapProps,
+): JSX.Element
+export interface CityChartState {
+ ready: boolean
+ error: string | null
+ environmentControls: ReactNode
+ weatherMode: 'auto' | CityWeather
+}
+export interface CityChartProps {
+ autoRotate?: boolean
+ definition: ChartDefinition
+ chartRef?: Ref>
+ id?: string
+ viewportId?: string
+ className?: string
+ theme?: 'default' | null
+ ariaLabel?: string
+ view?: CityView
+ life?: boolean
+ effects?: boolean
+ lightingMode?: CityLighting
+ weather?: CityWeather
+ showEnvironmentControls?: boolean
+ showDistrictLabels?: boolean
+ region?: 'all' | number
+ busy?: boolean
+ selected?: T | null
+ onSelect?: (point: ChartPoint | null) => void
+ onFocusChange?: (point: ChartPoint | null) => void
+ children?: ReactNode | ((state: CityChartState) => ReactNode)
+}
+export declare function CityChart(
+ props: CityChartProps,
+): JSX.Element
+export interface EnvironmentControlsProps {
+ lighting: CityLighting
+ weather: 'auto' | CityWeather
+ onLightingChange: (mode: CityLighting) => void
+ onWeatherChange: (weather: 'auto' | CityWeather) => void
+ disabled?: boolean
+}
+export declare function EnvironmentControls(
+ props: EnvironmentControlsProps,
+): JSX.Element
diff --git a/packages/react-charts/src/city/use-city-chart.js b/packages/react-charts/src/city/use-city-chart.js
new file mode 100644
index 00000000..81661acb
--- /dev/null
+++ b/packages/react-charts/src/city/use-city-chart.js
@@ -0,0 +1,345 @@
+import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect.js'
+import { useMapI18n } from './use-map-i18n.js'
+import {
+ useCallback,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { createCityRenderer } from './renderer.js'
+import { layoutCity } from './engine/model.js'
+import { CitySlider } from './engine/city-slider.js'
+import { mapTooltipAnchor } from './engine/lib/map-tooltip.js'
+import { mapNorthAngle } from './engine/lib/map-compass.js'
+
+// TanStack owns the renderer lifetime; this hook bridges camera and UI state.
+export function useCityChart({
+ definition,
+ busy,
+ selected,
+ region,
+ currentView,
+ life,
+ lightingMode,
+ weather,
+ effects,
+ autoRotate,
+ chartRef,
+ panelRef,
+ viewportRef,
+ labelsRef,
+ labelNodes,
+ onHover,
+ projectionStyleRef,
+ instanceId,
+}) {
+ const { t } = useMapI18n()
+ const cityRef = useRef(null)
+ const sliderRef = useRef(null)
+ const hoveredCompany = useRef(null)
+ const lastHover = useRef(null)
+ const hoverHideTimer = useRef(null)
+ const tooltipHovered = useRef(false)
+ const [layout, setLayout] = useState(() => layoutCity([]))
+ const latest = useRef(null)
+ latest.current = {
+ layout,
+ busy,
+ region,
+ currentView,
+ onHover,
+ }
+ const sceneRef = useRef(null)
+ const updateOverlays = useRef(null)
+ const [ready, setReady] = useState(false)
+ const [error, setError] = useState(null)
+
+ const cancelHoverHide = useCallback(() => {
+ clearTimeout(hoverHideTimer.current)
+ hoverHideTimer.current = null
+ }, [])
+ const clearHover = useCallback(() => {
+ cancelHoverHide()
+ hoveredCompany.current = null
+ lastHover.current = null
+ tooltipHovered.current = false
+ latest.current.onHover(null)
+ }, [cancelHoverHide])
+ const scheduleHoverHide = useCallback(() => {
+ if (tooltipHovered.current || hoverHideTimer.current !== null) return
+ // Allow crossing the small gap between the facade and the tooltip.
+ hoverHideTimer.current = setTimeout(() => {
+ hoverHideTimer.current = null
+ if (!tooltipHovered.current) clearHover()
+ }, 150)
+ }, [clearHover])
+ const onTooltipEnter = useCallback(() => {
+ tooltipHovered.current = true
+ cancelHoverHide()
+ }, [cancelHoverHide])
+ const onTooltipLeave = useCallback(() => {
+ tooltipHovered.current = false
+ scheduleHoverHide()
+ }, [scheduleHoverHide])
+
+ const renderer = useMemo(() => {
+ const labelRules = new Map()
+ let compassRule
+ let monitorText
+ const updateHover = (scene) => {
+ const company = hoveredCompany.current
+ let next = null
+ if (company && !latest.current.busy) {
+ const point = scene.project(company.x, company.roofTop, company.z)
+ const anchor = mapTooltipAnchor(
+ point,
+ viewportRef.current.clientWidth,
+ viewportRef.current.clientHeight,
+ )
+ if (anchor) next = { company, ...anchor }
+ }
+ const previous = lastHover.current
+ if (
+ previous?.company === next?.company &&
+ previous?.x === next?.x &&
+ previous?.y === next?.y
+ )
+ return
+ lastHover.current = next
+ latest.current.onHover(next)
+ }
+ const contextLost = (event) => {
+ event.preventDefault()
+ setError('contextLost')
+ }
+ const projectOverlays = (scene) => {
+ updateHover(scene)
+ const sheet = projectionStyleRef.current?.sheet
+ if (sheet) {
+ if (!compassRule) {
+ const index = sheet.insertRule(
+ `[data-atlas-city-instance="${instanceId}"] {}`,
+ sheet.cssRules.length,
+ )
+ compassRule = sheet.cssRules[index]
+ }
+ compassRule.style.setProperty(
+ '--atlas-city-north-angle',
+ `${mapNorthAngle(scene.camera)}rad`,
+ )
+ }
+ const { region, layout } = latest.current
+ const width = viewportRef.current?.clientWidth || 0
+ const height = viewportRef.current?.clientHeight || 0
+ for (const [mask, node] of labelNodes.current) {
+ const anchor = layout.anchors[mask]
+ if (!anchor) continue
+ const point = scene.project(...anchor)
+ const sheet = projectionStyleRef.current?.sheet
+ if (sheet) {
+ if (!labelRules.has(mask)) {
+ const index = sheet.insertRule(
+ `[data-atlas-city-instance="${instanceId}"] .atlas-city-label[data-region="${mask}"] {}`,
+ sheet.cssRules.length,
+ )
+ labelRules.set(mask, sheet.cssRules[index])
+ }
+ const rule = labelRules.get(mask)
+ rule.style.left = `${(point.x * 0.5 + 0.5) * width}px`
+ rule.style.top = `${(-point.y * 0.5 + 0.5) * height}px`
+ }
+ node.dataset.dimmed = String(
+ region !== 'all' && Number(region) !== mask,
+ )
+ node.hidden = point.z > 1 || point.z < -1 || scene.camera.zoom > 3.3
+ }
+ }
+ updateOverlays.current = projectOverlays
+ return createCityRenderer({
+ hover: (company) => {
+ if (!company) {
+ scheduleHoverHide()
+ return
+ }
+ cancelHoverHide()
+ hoveredCompany.current = company
+ if (cityRef.current) updateHover(cityRef.current)
+ },
+ projection: projectOverlays,
+ frame(scene) {
+ // Three.js colors are linear: choose the higher-contrast text color
+ // against the scene, including during day/night lighting transitions.
+ const { r, g, b } = scene.scene.background
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
+ const color = luminance > 0.179 ? '#000' : '#fff'
+ if (compassRule && color !== monitorText) {
+ compassRule.style.setProperty('--atlas-city-monitor-text', color)
+ monitorText = color
+ }
+ },
+ onMount(city) {
+ cityRef.current = city
+ city.renderer.domElement.addEventListener(
+ 'webglcontextlost',
+ contextLost,
+ )
+ setReady(true)
+ },
+ onData(city, { resetView }) {
+ if (resetView) city.view(latest.current.currentView, true)
+ latest.current.layout = city.layout
+ setLayout(city.layout)
+ setError(null)
+ },
+ onError(error) {
+ console.error('Unable to prepare the 3D city.', error)
+ setError('webglError')
+ },
+ onDestroy(city) {
+ cancelHoverHide()
+ sliderRef.current?.dispose()
+ city.renderer.domElement.removeEventListener(
+ 'webglcontextlost',
+ contextLost,
+ )
+ cityRef.current = null
+ sliderRef.current = null
+ hoveredCompany.current = null
+ lastHover.current = null
+ tooltipHovered.current = false
+ },
+ })
+ }, [
+ panelRef,
+ viewportRef,
+ labelsRef,
+ labelNodes,
+ cancelHoverHide,
+ scheduleHoverHide,
+ projectionStyleRef,
+ instanceId,
+ ])
+
+ useIsomorphicLayoutEffect(() => () => cancelHoverHide(), [cancelHoverHide])
+
+ // Chart mounts its renderer in a child layout effect. Parent/sibling refs
+ // become available only here, after that effect has completed.
+ useIsomorphicLayoutEffect(() => {
+ if (cityRef.current && !sliderRef.current) {
+ sliderRef.current = new CitySlider(
+ cityRef.current,
+ panelRef.current,
+ labelsRef.current,
+ )
+ }
+ }, [panelRef, labelsRef])
+
+ useIsomorphicLayoutEffect(() => {
+ clearHover()
+ }, [definition, busy, selected, clearHover])
+
+ useIsomorphicLayoutEffect(() => {
+ const city = cityRef.current
+ if (city) {
+ city.setActivity({ life, effects })
+ }
+ }, [life, effects, layout])
+
+ useIsomorphicLayoutEffect(() => {
+ cityRef.current?.setAutoRotate(autoRotate)
+ }, [autoRotate, layout])
+
+ useIsomorphicLayoutEffect(() => {
+ cityRef.current?.setLightingMode(lightingMode)
+ }, [lightingMode, layout])
+
+ useIsomorphicLayoutEffect(() => {
+ cityRef.current?.setWeather(weather)
+ }, [weather, layout])
+
+ useIsomorphicLayoutEffect(() => {
+ cityRef.current?.view(currentView)
+ }, [currentView])
+
+ useIsomorphicLayoutEffect(() => {
+ const city = cityRef.current
+ if (!city) return
+ city.select(city.companiesById?.get(selected?.id))
+ }, [selected, layout])
+
+ useIsomorphicLayoutEffect(() => {
+ if (cityRef.current) updateOverlays.current?.(cityRef.current)
+ }, [layout, region])
+
+ useImperativeHandle(
+ chartRef,
+ () => ({
+ cancel() {
+ sliderRef.current?.cancel()
+ if (cityRef.current) {
+ cityRef.current.controls.enabled = true
+ cityRef.current.invalidate()
+ }
+ },
+ deselect() {
+ cityRef.current?.select(null)
+ },
+ select(company, { focus = false, maxZoom = false } = {}) {
+ const city = cityRef.current
+ if (!city) return
+ const rendered = city.companiesById?.get(company.id)
+ if (!rendered) return
+ city.select(rendered)
+ if (focus) city.focus(rendered, { maxZoom })
+ },
+ zoom(factor) {
+ cityRef.current?.zoom(factor)
+ },
+ view(view) {
+ cityRef.current?.view(view)
+ },
+ capture() {
+ sliderRef.current?.capture()
+ if (cityRef.current) cityRef.current.controls.enabled = false
+ },
+ play(direction) {
+ return sliderRef.current?.play(direction)
+ },
+ scrollIntoView() {
+ panelRef.current?.scrollIntoView({
+ block: 'start',
+ behavior: cityRef.current?.reduceMotion ? 'instant' : 'smooth',
+ })
+ viewportRef.current
+ ?.querySelector('[data-city-surface]')
+ ?.focus({ preventScroll: true })
+ },
+ stats() {
+ const renderer = cityRef.current?.renderer
+ return {
+ drawCalls: renderer?.info.render.calls ?? 0,
+ triangles: renderer?.info.render.triangles ?? 0,
+ chartRenderer: 'atlas-city-3d',
+ chartPoints: sceneRef.current?.points.length ?? 0,
+ lightingMode: cityRef.current?.lightingMode,
+ lightingPeriod: cityRef.current?.lightingPeriod,
+ weather: cityRef.current?.weather,
+ weatherVisible: cityRef.current?.weatherParticles?.visible ?? false,
+ }
+ },
+ }),
+ [chartRef, panelRef, viewportRef],
+ )
+ return {
+ renderer,
+ layout,
+ ready,
+ error: error ? t(error) : null,
+ onTooltipEnter,
+ onTooltipLeave,
+ onRender({ scene }) {
+ sceneRef.current = scene
+ },
+ }
+}
diff --git a/packages/react-charts/src/city/use-fullscreen-wake-lock.js b/packages/react-charts/src/city/use-fullscreen-wake-lock.js
new file mode 100644
index 00000000..eab1af02
--- /dev/null
+++ b/packages/react-charts/src/city/use-fullscreen-wake-lock.js
@@ -0,0 +1,17 @@
+import { useEffect, useState } from 'react'
+import { maintainFullscreenWakeLock } from './engine/lib/fullscreen-wake-lock.js'
+
+export function useFullscreenWakeLock(elementRef) {
+ const [status, setStatus] = useState('idle')
+ useEffect(
+ () =>
+ maintainFullscreenWakeLock({
+ document,
+ navigator,
+ element: () => elementRef.current,
+ onStatus: setStatus,
+ }),
+ [elementRef],
+ )
+ return status
+}
diff --git a/packages/react-charts/src/city/use-isomorphic-layout-effect.js b/packages/react-charts/src/city/use-isomorphic-layout-effect.js
new file mode 100644
index 00000000..8a4f8480
--- /dev/null
+++ b/packages/react-charts/src/city/use-isomorphic-layout-effect.js
@@ -0,0 +1,4 @@
+import { useEffect, useLayoutEffect } from 'react'
+
+export const useIsomorphicLayoutEffect =
+ typeof document === 'undefined' ? useEffect : useLayoutEffect
diff --git a/packages/react-charts/src/city/use-map-i18n.js b/packages/react-charts/src/city/use-map-i18n.js
new file mode 100644
index 00000000..c8f425b1
--- /dev/null
+++ b/packages/react-charts/src/city/use-map-i18n.js
@@ -0,0 +1,19 @@
+import { useMemo, useSyncExternalStore } from 'react'
+import { getBrowserMapLocale, getMapI18n } from './i18n.js'
+
+function subscribe(onChange) {
+ window.addEventListener('languagechange', onChange)
+ return () => window.removeEventListener('languagechange', onChange)
+}
+
+// A stable English server snapshot also keeps the first hydration render consistent.
+const serverLocale = () => 'en-US'
+
+export function useMapI18n() {
+ const locale = useSyncExternalStore(
+ subscribe,
+ getBrowserMapLocale,
+ serverLocale,
+ )
+ return useMemo(() => getMapI18n(locale), [locale])
+}
diff --git a/packages/react-charts/src/exports.test.ts b/packages/react-charts/src/exports.test.ts
index 6e52a67e..f9ccf11e 100644
--- a/packages/react-charts/src/exports.test.ts
+++ b/packages/react-charts/src/exports.test.ts
@@ -7,6 +7,7 @@ import packageJson from '../package.json'
describe('React package exports', () => {
it.each([
'index.ts',
+ 'city.ts',
'core.ts',
'canvas.ts',
'tooltip.tsx',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 65a6b109..da60c5e7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -217,6 +217,9 @@ importers:
svelte:
specifier: ^5.56.2
version: 5.56.8
+ three:
+ specifier: 0.180.0
+ version: 0.180.0
topojson-client:
specifier: 3.1.0
version: 3.1.0
@@ -627,6 +630,9 @@ importers:
svelte:
specifier: ^5.20.0
version: 5.56.8
+ three:
+ specifier: ^0.180.0
+ version: 0.180.0
tslib:
specifier: ^2.8.1
version: 2.8.1
@@ -779,6 +785,9 @@ importers:
react-dom:
specifier: ^18.0.0 || ^19.0.0
version: 19.2.3(react@19.2.3)
+ three:
+ specifier: ^0.180.0
+ version: 0.180.0
devDependencies:
'@types/d3-scale':
specifier: ^4.0.9
@@ -9265,6 +9274,12 @@ packages:
engines: { node: '>=10' }
hasBin: true
+ three@0.180.0:
+ resolution:
+ {
+ integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==,
+ }
+
throat@5.0.0:
resolution:
{
@@ -15754,6 +15769,8 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
+ three@0.180.0: {}
+
throat@5.0.0: {}
tiny-invariant@1.3.3: {}
diff --git a/scripts/catalog-definition-shapes.test.mjs b/scripts/catalog-definition-shapes.test.mjs
index 319683ac..c32c3366 100644
--- a/scripts/catalog-definition-shapes.test.mjs
+++ b/scripts/catalog-definition-shapes.test.mjs
@@ -68,10 +68,10 @@ describe('catalog definition shapes', () => {
)
expect(classification.parameterless).toEqual([])
- expect(classification.static).toBe(175)
+ expect(classification.static).toBe(176)
expect(classification.responsive.sort()).toEqual(responsiveDefinitions)
expect(classification.indirect.sort()).toEqual(indirectDefinitions)
- expect(classification.staticFiles).toHaveLength(170)
+ expect(classification.staticFiles).toHaveLength(171)
expect(
new Set([
...classification.staticFiles,
diff --git a/scripts/check-packed-consumers.mjs b/scripts/check-packed-consumers.mjs
index b5779582..458c98b9 100644
--- a/scripts/check-packed-consumers.mjs
+++ b/scripts/check-packed-consumers.mjs
@@ -332,9 +332,9 @@ async function buildRuntime(packageInfo) {
}
function isRuntimeSource(file) {
- if (!/\.(?:ts|tsx)$/.test(file)) return false
+ if (!/\.(?:ts|tsx|js|jsx)$/.test(file)) return false
if (/\.d\.ts$/.test(file)) return false
- if (/\.(?:test|type-test)\.(?:ts|tsx)$/.test(file)) return false
+ if (/\.(?:test|type-test)\.(?:ts|tsx|js|jsx)$/.test(file)) return false
return !file.endsWith(`${sep}test-scales.ts`)
}
@@ -460,8 +460,8 @@ async function rewriteGeneratedSpecifiers(directory) {
function toJavaScriptSpecifier(specifier) {
if (/\.(?:[cm]?js|json|css|wasm)$/.test(specifier)) return specifier
- if (/\.(?:ts|tsx|tsrx)$/.test(specifier)) {
- return specifier.replace(/\.(?:ts|tsx|tsrx)$/, '.js')
+ if (/\.(?:ts|tsx|tsrx|jsx)$/.test(specifier)) {
+ return specifier.replace(/\.(?:ts|tsx|tsrx|jsx)$/, '.js')
}
return `${specifier}.js`
}
@@ -580,6 +580,7 @@ async function installFixture(tarballs) {
react: installedDependency('react'),
'react-dom': installedDependency('react-dom'),
tslib: installedPackageDependency('angular-charts', 'tslib'),
+ three: installedDependency('three'),
}
const manifest = {
name: 'tanstack-charts-packed-consumer',
@@ -673,6 +674,7 @@ async function verifyReact18PackedConsumer(tarballs) {
'@types/react-dom': installedReact18Dependency('@types/react-dom'),
react: installedReact18Dependency('react'),
'react-dom': installedReact18Dependency('react-dom'),
+ three: installedDependency('three'),
}
await mkdir(react18FixtureDirectory, { recursive: true })
@@ -781,6 +783,9 @@ import { renderToStaticMarkup } from 'react-dom/server'
const originalConsoleError = console.error
console.error = (...values) => serverWarnings.push(values.map(String).join(' '))
try {
+ const cityMarkup = renderToStaticMarkup(createElement(CityMap, { data: [], ariaLabel: 'React 18 city' }))
+ assert.match(cityMarkup, /data-city-surface/)
+ assert.doesNotMatch(cityMarkup, /
+ : `export const cityMap =
+export const compatibilityChart =
export const unifiedChart = `
return `
${runtimeImports}
import { Chart as CompatibilityChart } from '@tanstack/react-charts'
+import { CityMap } from '@tanstack/react-charts/city'
import { defineChart, lineY } from '@tanstack/charts'
import { Chart as UnifiedChart } from '@tanstack/charts/react'
import { scaleLinear } from '@tanstack/charts/scales/linear'
@@ -899,6 +906,7 @@ async function verifyEsmRuntime() {
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { renderToString } from 'octane/server'
+ import { CityMap, defineCityChart, createCityRenderer } from '@tanstack/react-charts/city'
import { scaleBand, scaleLinear, scalePoint } from 'd3-scale'
import { scaleBand as compactScaleBand } from '@tanstack/charts-scales/band'
import { scaleLinear as compactScaleLinear } from '@tanstack/charts-scales/linear'
@@ -960,6 +968,14 @@ async function verifyEsmRuntime() {
'@tanstack/charts/types',
'@tanstack/charts/types/core',
])
+ const cityRows = [{ id: 1, name: 'Packed store', metric: { label: 'Orders', value: 42 }, categories: [{ value: 'retail', label: 'Retail' }], status: 'healthy' }]
+ const cityHtml = renderToStaticMarkup(createElement(CityMap, { data: cityRows, ariaLabel: 'Packed city' }))
+ assert.match(cityHtml, /data-city-surface/)
+ assert.match(cityHtml, /Packed city/)
+ assert.doesNotMatch(cityHtml, /