From 7550948b161a928ab235cfb0e285b13175c260b4 Mon Sep 17 00:00:00 2001 From: Raphael Almeida Araujo Date: Wed, 21 Oct 2026 09:00:11 -0300 Subject: [PATCH 1/5] feat(react): add optional Atlas city map --- .changeset/atlas-city-map.md | 6 + API-FRICTION.md | 17 + docs/config.json | 4 + docs/framework/react/adapter.md | 4 + docs/framework/react/city.md | 134 ++++ examples/charts-react/city/index.html | 12 + examples/charts-react/src/city-main.tsx | 66 ++ examples/charts-react/vite.config.ts | 7 + llms.txt | 1 + package.json | 3 +- packages/charts-core/docs/config.json | 4 + .../docs/framework/react/adapter.md | 4 + .../charts-core/docs/framework/react/city.md | 134 ++++ packages/charts-core/llms.txt | 1 + packages/charts-core/package.json | 29 +- packages/charts-core/src/react-city.ts | 3 + .../charts-core/src/react-city/styles.css | 1 + .../src/react-city/theme-default.css | 1 + .../src/react-city/theme-tailwind.css | 1 + packages/react-charts/package.json | 31 +- packages/react-charts/src/city.test.tsx | 52 ++ packages/react-charts/src/city.ts | 34 + .../react-charts/src/city/company-monitor.jsx | 159 ++++ packages/react-charts/src/city/data.js | 61 ++ packages/react-charts/src/city/definition.js | 24 + .../src/city/engine/city-slider.js | 100 +++ packages/react-charts/src/city/engine/city.js | 469 ++++++++++++ .../src/city/engine/critical-blink.js | 20 + .../react-charts/src/city/engine/landscape.js | 63 ++ .../src/city/engine/lib/company-dates.js | 63 ++ .../city/engine/lib/fullscreen-wake-lock.js | 71 ++ .../src/city/engine/lib/map-compass.js | 6 + .../src/city/engine/lib/map-tooltip.js | 23 + .../react-charts/src/city/engine/model.js | 416 +++++++++++ .../src/city/engine/plume-renderer.js | 116 +++ .../react-charts/src/city/engine/plumes.js | 145 ++++ .../src/city/engine/scene-resources.js | 26 + .../src/city/engine/selection-highlight.js | 54 ++ .../react-charts/src/city/engine/traffic.js | 331 +++++++++ .../src/city/environment-controls.d.ts | 1 + .../src/city/environment-controls.jsx | 66 ++ packages/react-charts/src/city/i18n.js | 246 +++++++ packages/react-charts/src/city/index.d.ts | 6 + packages/react-charts/src/city/index.js | 4 + packages/react-charts/src/city/interaction.js | 19 + packages/react-charts/src/city/layout.js | 13 + packages/react-charts/src/city/map.d.ts | 1 + packages/react-charts/src/city/map.jsx | 502 +++++++++++++ packages/react-charts/src/city/mark.js | 63 ++ packages/react-charts/src/city/react.d.ts | 1 + packages/react-charts/src/city/react.jsx | 256 +++++++ packages/react-charts/src/city/renderer.js | 283 ++++++++ packages/react-charts/src/city/styles.css | 687 ++++++++++++++++++ .../react-charts/src/city/theme-default.css | 55 ++ .../react-charts/src/city/theme-tailwind.css | 71 ++ .../src/city/three/building-material.js | 39 + .../react-charts/src/city/three/buildings.js | 121 +++ .../react-charts/src/city/three/effects.js | 113 +++ .../react-charts/src/city/three/lighting.js | 164 +++++ .../react-charts/src/city/three/primitives.js | 33 + .../react-charts/src/city/three/snow-cover.js | 95 +++ .../react-charts/src/city/three/terrain.js | 200 +++++ .../react-charts/src/city/three/traffic.js | 204 ++++++ .../react-charts/src/city/three/weather.js | 251 +++++++ packages/react-charts/src/city/types.ts | 181 +++++ .../react-charts/src/city/use-city-chart.js | 345 +++++++++ .../src/city/use-fullscreen-wake-lock.js | 17 + .../src/city/use-isomorphic-layout-effect.js | 4 + .../react-charts/src/city/use-map-i18n.js | 19 + packages/react-charts/src/exports.test.ts | 12 +- pnpm-lock.yaml | 17 + scripts/check-city-map.mjs | 76 ++ scripts/check-packed-consumers.mjs | 55 +- scripts/city/camera.test.mjs | 133 ++++ scripts/city/city-chart.test.mjs | 158 ++++ scripts/city/city-i18n.test.mjs | 55 ++ scripts/city/city-lighting.test.mjs | 154 ++++ scripts/city/city-slider.test.mjs | 138 ++++ scripts/city/critical-blink.test.mjs | 36 + scripts/city/fullscreen-wake-lock.test.mjs | 133 ++++ scripts/city/landscape.test.mjs | 96 +++ scripts/city/map-compass.test.mjs | 42 ++ scripts/city/map-tooltip.test.mjs | 30 + scripts/city/plume-renderer.test.mjs | 41 ++ scripts/city/plumes.test.mjs | 96 +++ scripts/city/scene-resources.test.mjs | 40 + scripts/city/traffic.test.mjs | 109 +++ scripts/public-callback-contract.mjs | 27 + scripts/unified-package-artifact.mjs | 20 + 89 files changed, 8200 insertions(+), 24 deletions(-) create mode 100644 .changeset/atlas-city-map.md create mode 100644 docs/framework/react/city.md create mode 100644 examples/charts-react/city/index.html create mode 100644 examples/charts-react/src/city-main.tsx create mode 100644 packages/charts-core/docs/framework/react/city.md create mode 100644 packages/charts-core/src/react-city.ts create mode 100644 packages/charts-core/src/react-city/styles.css create mode 100644 packages/charts-core/src/react-city/theme-default.css create mode 100644 packages/charts-core/src/react-city/theme-tailwind.css create mode 100644 packages/react-charts/src/city.test.tsx create mode 100644 packages/react-charts/src/city.ts create mode 100644 packages/react-charts/src/city/company-monitor.jsx create mode 100644 packages/react-charts/src/city/data.js create mode 100644 packages/react-charts/src/city/definition.js create mode 100644 packages/react-charts/src/city/engine/city-slider.js create mode 100644 packages/react-charts/src/city/engine/city.js create mode 100644 packages/react-charts/src/city/engine/critical-blink.js create mode 100644 packages/react-charts/src/city/engine/landscape.js create mode 100644 packages/react-charts/src/city/engine/lib/company-dates.js create mode 100644 packages/react-charts/src/city/engine/lib/fullscreen-wake-lock.js create mode 100644 packages/react-charts/src/city/engine/lib/map-compass.js create mode 100644 packages/react-charts/src/city/engine/lib/map-tooltip.js create mode 100644 packages/react-charts/src/city/engine/model.js create mode 100644 packages/react-charts/src/city/engine/plume-renderer.js create mode 100644 packages/react-charts/src/city/engine/plumes.js create mode 100644 packages/react-charts/src/city/engine/scene-resources.js create mode 100644 packages/react-charts/src/city/engine/selection-highlight.js create mode 100644 packages/react-charts/src/city/engine/traffic.js create mode 100644 packages/react-charts/src/city/environment-controls.d.ts create mode 100644 packages/react-charts/src/city/environment-controls.jsx create mode 100644 packages/react-charts/src/city/i18n.js create mode 100644 packages/react-charts/src/city/index.d.ts create mode 100644 packages/react-charts/src/city/index.js create mode 100644 packages/react-charts/src/city/interaction.js create mode 100644 packages/react-charts/src/city/layout.js create mode 100644 packages/react-charts/src/city/map.d.ts create mode 100644 packages/react-charts/src/city/map.jsx create mode 100644 packages/react-charts/src/city/mark.js create mode 100644 packages/react-charts/src/city/react.d.ts create mode 100644 packages/react-charts/src/city/react.jsx create mode 100644 packages/react-charts/src/city/renderer.js create mode 100644 packages/react-charts/src/city/styles.css create mode 100644 packages/react-charts/src/city/theme-default.css create mode 100644 packages/react-charts/src/city/theme-tailwind.css create mode 100644 packages/react-charts/src/city/three/building-material.js create mode 100644 packages/react-charts/src/city/three/buildings.js create mode 100644 packages/react-charts/src/city/three/effects.js create mode 100644 packages/react-charts/src/city/three/lighting.js create mode 100644 packages/react-charts/src/city/three/primitives.js create mode 100644 packages/react-charts/src/city/three/snow-cover.js create mode 100644 packages/react-charts/src/city/three/terrain.js create mode 100644 packages/react-charts/src/city/three/traffic.js create mode 100644 packages/react-charts/src/city/three/weather.js create mode 100644 packages/react-charts/src/city/types.ts create mode 100644 packages/react-charts/src/city/use-city-chart.js create mode 100644 packages/react-charts/src/city/use-fullscreen-wake-lock.js create mode 100644 packages/react-charts/src/city/use-isomorphic-layout-effect.js create mode 100644 packages/react-charts/src/city/use-map-i18n.js create mode 100644 scripts/check-city-map.mjs create mode 100644 scripts/city/camera.test.mjs create mode 100644 scripts/city/city-chart.test.mjs create mode 100644 scripts/city/city-i18n.test.mjs create mode 100644 scripts/city/city-lighting.test.mjs create mode 100644 scripts/city/city-slider.test.mjs create mode 100644 scripts/city/critical-blink.test.mjs create mode 100644 scripts/city/fullscreen-wake-lock.test.mjs create mode 100644 scripts/city/landscape.test.mjs create mode 100644 scripts/city/map-compass.test.mjs create mode 100644 scripts/city/map-tooltip.test.mjs create mode 100644 scripts/city/plume-renderer.test.mjs create mode 100644 scripts/city/plumes.test.mjs create mode 100644 scripts/city/scene-resources.test.mjs create mode 100644 scripts/city/traffic.test.mjs diff --git a/.changeset/atlas-city-map.md b/.changeset/atlas-city-map.md new file mode 100644 index 00000000..19672d81 --- /dev/null +++ b/.changeset/atlas-city-map.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-charts': minor +'@tanstack/charts': minor +--- + +Add an optional interactive 3D city map for React, with categorical districts, metric-driven buildings, selection, camera controls, weather, and customizable CSS themes. Import the city entry and install the optional Three.js peer to use it. diff --git a/API-FRICTION.md b/API-FRICTION.md index dbcc9ffb..ded00002 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -351,6 +351,7 @@ Each entry records: | F-312 | Tick stubs could not be styled independently | API | resolved | | F-313 | Initial observer precision cancelled entrance motion | Tooling | resolved | | F-314 | Framework ticks and immediate focus layers were unclear | Documentation | resolved | +| F-315 | Optional React city assets needed packaging support | Tooling | monitoring | ## Findings @@ -9354,3 +9355,19 @@ Each entry records: direction changes. A focus dot appears during entrance while the data path and its running animation remain intact. Concurrent inline states remain a separate feature; this addresses the documented alternatives requested. + +### F-315 - Optional React renderers could not ship JavaScript and CSS assets + +- Status: monitoring +- Severity: medium +- Owner: Tooling +- Observed in: porting the Atlas city renderer to the React package +- Friction: the packed consumer builder accepted only TypeScript runtime files, + required declarations for stylesheet exports, and required sideEffects to be + false even for explicitly imported stylesheets. +- Decision: accept JavaScript runtime modules alongside a checked TypeScript + public contract, copy CSS assets, and permit CSS-only side effects. Keep the + renderer behind an optional entry and Three.js behind an optional peer. +- Verification: tracked by the city integration tests and packed package gate. +- Follow-up: consider migrating the private JavaScript engine to TypeScript + independently of the public React API. diff --git a/docs/config.json b/docs/config.json index 87ed2e2d..c803042a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -45,6 +45,10 @@ { "label": "React Adapter", "to": "framework/react/adapter" + }, + { + "label": "3D City Map", + "to": "framework/react/city" } ] }, diff --git a/docs/framework/react/adapter.md b/docs/framework/react/adapter.md index 8a128a05..57d8cf4e 100644 --- a/docs/framework/react/adapter.md +++ b/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](./city.md) for metric-driven buildings, categorical districts, and React controls. diff --git a/docs/framework/react/city.md b/docs/framework/react/city.md new file mode 100644 index 00000000..b44b855f --- /dev/null +++ b/docs/framework/react/city.md @@ -0,0 +1,134 @@ +--- +title: 3D city map +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 and its stylesheet in your client component: + +```tsx +'use client' + +import { CityMap, type CityDatum } from '@tanstack/charts/react/city' +import '@tanstack/charts/react/city/styles.css' + +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`; its styles are at +`@tanstack/react-charts/city/styles.css`. + +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. + +## 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 stylesheet supplies default theme variables. Override `--atlas-city-*` +properties in your own CSS, or import `city/theme-tailwind.css` and set +`theme="tailwind"` to use host Tailwind theme variables. Set `theme={null}` to +provide all variables yourself. The map needs a container with a measurable width +and reserves at least 520 pixels of height. + +## Rendering and accessibility + +Use a client boundary for React Server Components. Server rendering outputs an +accessible placeholder; WebGL is allocated only when the chart mounts in a browser. +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. + +## Local example + +Run `pnpm dev:charts-react` from the repository root and open `/city/` to try +the complete map, including search, selection, weather, and mount/unmount controls. +Run `node scripts/check-city-map.mjs` for the Chromium interaction check. + +## 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. + +Stylesheet entry points are +`@tanstack/charts/react/city/styles.css`, +`@tanstack/charts/react/city/theme-default.css`, and +`@tanstack/charts/react/city/theme-tailwind.css`. diff --git a/examples/charts-react/city/index.html b/examples/charts-react/city/index.html new file mode 100644 index 00000000..de2e6c10 --- /dev/null +++ b/examples/charts-react/city/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Charts — City map + + +
+ + + diff --git a/examples/charts-react/src/city-main.tsx b/examples/charts-react/src/city-main.tsx new file mode 100644 index 00000000..acb3f566 --- /dev/null +++ b/examples/charts-react/src/city-main.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { + CityMap, + type CityDatum, + type CityChartHandle, +} from '@tanstack/charts/react/city' +import '@tanstack/charts/react/city/styles.css' + +const categories = [ + { value: 'retail', label: 'Retail' }, + { value: 'online', label: 'Online' }, + { value: 'wholesale', label: 'Wholesale' }, +] +const records: CityDatum[] = Array.from({ length: 140 }, (_, index) => ({ + id: `store-${index}`, + name: `Store ${String(index + 1).padStart(3, '0')}`, + metric: { label: 'Orders', value: ((index * 397) % 5000) + 100 }, + categories: categories.filter((_, bit) => ((index % 7) + 1) & (1 << bit)), + status: + index % 9 === 0 ? 'critical' : index % 5 === 0 ? 'warning' : 'healthy', +})) + +function App() { + const [selected, setSelected] = React.useState(null) + const [mounted, setMounted] = React.useState(true) + const handle = React.useRef(null) + return ( +
+

Store health city

+

+ Building height represents orders. Districts show category + intersections. +

+ + +

+ {selected?.name ?? 'No store selected'} +

+ {mounted && ( + + )} +
+ ) +} + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/examples/charts-react/vite.config.ts b/examples/charts-react/vite.config.ts index aa5ecf6b..a114fa0b 100644 --- a/examples/charts-react/vite.config.ts +++ b/examples/charts-react/vite.config.ts @@ -1,9 +1,16 @@ import react from '@vitejs/plugin-react' +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], build: { target: 'esnext', + rollupOptions: { + input: { + main: fileURLToPath(new URL('./index.html', import.meta.url)), + city: fileURLToPath(new URL('./city/index.html', import.meta.url)), + }, + }, }, }) diff --git a/llms.txt b/llms.txt index 4fca1560..d32272ea 100644 --- a/llms.txt +++ b/llms.txt @@ -12,6 +12,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam - docs/quick-start.md: Quick Start: Build, mount, update, and clean up a responsive TanStack Charts line chart with fully inferred types. - docs/framework/react/quick-start.md: React Quick Start: Install the React adapter, define a typed chart, render responsive SVG, and add native interaction. - docs/framework/react/adapter.md: React Adapter: Understand the thin React lifecycle, SSR, hydration, update, sizing, class, and style behavior around the shared chart host. +- docs/framework/react/city.md: 3D city map: Render metric-driven buildings with categorical districts, selection, camera controls, lighting, and weather in React. - docs/framework/preact/adapter.md: Preact Adapter: Render and hydrate TanStack Charts with Preact. - docs/framework/vue/adapter.md: Vue Adapter: Render and hydrate TanStack Charts with Vue 3. - docs/framework/solid/adapter.md: Solid Adapter: Render and hydrate TanStack Charts with Solid. 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..c803042a 100644 --- a/packages/charts-core/docs/config.json +++ b/packages/charts-core/docs/config.json @@ -45,6 +45,10 @@ { "label": "React Adapter", "to": "framework/react/adapter" + }, + { + "label": "3D City Map", + "to": "framework/react/city" } ] }, diff --git a/packages/charts-core/docs/framework/react/adapter.md b/packages/charts-core/docs/framework/react/adapter.md index 8a128a05..57d8cf4e 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](./city.md) for metric-driven buildings, categorical districts, and React controls. diff --git a/packages/charts-core/docs/framework/react/city.md b/packages/charts-core/docs/framework/react/city.md new file mode 100644 index 00000000..b44b855f --- /dev/null +++ b/packages/charts-core/docs/framework/react/city.md @@ -0,0 +1,134 @@ +--- +title: 3D city map +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 and its stylesheet in your client component: + +```tsx +'use client' + +import { CityMap, type CityDatum } from '@tanstack/charts/react/city' +import '@tanstack/charts/react/city/styles.css' + +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`; its styles are at +`@tanstack/react-charts/city/styles.css`. + +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. + +## 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 stylesheet supplies default theme variables. Override `--atlas-city-*` +properties in your own CSS, or import `city/theme-tailwind.css` and set +`theme="tailwind"` to use host Tailwind theme variables. Set `theme={null}` to +provide all variables yourself. The map needs a container with a measurable width +and reserves at least 520 pixels of height. + +## Rendering and accessibility + +Use a client boundary for React Server Components. Server rendering outputs an +accessible placeholder; WebGL is allocated only when the chart mounts in a browser. +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. + +## Local example + +Run `pnpm dev:charts-react` from the repository root and open `/city/` to try +the complete map, including search, selection, weather, and mount/unmount controls. +Run `node scripts/check-city-map.mjs` for the Chromium interaction check. + +## 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. + +Stylesheet entry points are +`@tanstack/charts/react/city/styles.css`, +`@tanstack/charts/react/city/theme-default.css`, and +`@tanstack/charts/react/city/theme-tailwind.css`. diff --git a/packages/charts-core/llms.txt b/packages/charts-core/llms.txt index 4fca1560..d32272ea 100644 --- a/packages/charts-core/llms.txt +++ b/packages/charts-core/llms.txt @@ -12,6 +12,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam - docs/quick-start.md: Quick Start: Build, mount, update, and clean up a responsive TanStack Charts line chart with fully inferred types. - docs/framework/react/quick-start.md: React Quick Start: Install the React adapter, define a typed chart, render responsive SVG, and add native interaction. - docs/framework/react/adapter.md: React Adapter: Understand the thin React lifecycle, SSR, hydration, update, sizing, class, and style behavior around the shared chart host. +- docs/framework/react/city.md: 3D city map: Render metric-driven buildings with categorical districts, selection, camera controls, lighting, and weather in React. - docs/framework/preact/adapter.md: Preact Adapter: Render and hydrate TanStack Charts with Preact. - docs/framework/vue/adapter.md: Vue Adapter: Render and hydrate TanStack Charts with Vue 3. - docs/framework/solid/adapter.md: Solid Adapter: Render and hydrate TanStack Charts with Solid. diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json index 74dcfef5..652f6c03 100644 --- a/packages/charts-core/package.json +++ b/packages/charts-core/package.json @@ -12,7 +12,9 @@ "directory": "packages/charts-core" }, "type": "module", - "sideEffects": false, + "sideEffects": [ + "**/*.css" + ], "files": [ "dist", "docs", @@ -55,7 +57,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 +99,9 @@ }, "vue": { "optional": true + }, + "three": { + "optional": true } }, "devDependencies": { @@ -225,7 +231,11 @@ "./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", + "./react/city/styles.css": "./src/react-city/styles.css", + "./react/city/theme-default.css": "./src/react-city/theme-default.css", + "./react/city/theme-tailwind.css": "./src/react-city/theme-tailwind.css" }, "publishConfig": { "access": "public", @@ -706,6 +716,19 @@ "./waffle": { "types": "./dist/waffle.d.ts", "import": "./dist/waffle.js" + }, + "./react/city": { + "types": "./dist/react/city.d.ts", + "import": "./dist/react/city.js" + }, + "./react/city/styles.css": { + "default": "./dist/react/city/styles.css" + }, + "./react/city/theme-default.css": { + "default": "./dist/react/city/theme-default.css" + }, + "./react/city/theme-tailwind.css": { + "default": "./dist/react/city/theme-tailwind.css" } } } 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/charts-core/src/react-city/styles.css b/packages/charts-core/src/react-city/styles.css new file mode 100644 index 00000000..f4dac268 --- /dev/null +++ b/packages/charts-core/src/react-city/styles.css @@ -0,0 +1 @@ +@import '../../../react-charts/src/city/styles.css'; diff --git a/packages/charts-core/src/react-city/theme-default.css b/packages/charts-core/src/react-city/theme-default.css new file mode 100644 index 00000000..4540eb84 --- /dev/null +++ b/packages/charts-core/src/react-city/theme-default.css @@ -0,0 +1 @@ +@import '../../../react-charts/src/city/theme-default.css'; diff --git a/packages/charts-core/src/react-city/theme-tailwind.css b/packages/charts-core/src/react-city/theme-tailwind.css new file mode 100644 index 00000000..5b617850 --- /dev/null +++ b/packages/charts-core/src/react-city/theme-tailwind.css @@ -0,0 +1 @@ +@import '../../../react-charts/src/city/theme-tailwind.css'; diff --git a/packages/react-charts/package.json b/packages/react-charts/package.json index 15c1b24c..e57828f7 100644 --- a/packages/react-charts/package.json +++ b/packages/react-charts/package.json @@ -9,7 +9,9 @@ "directory": "packages/react-charts" }, "type": "module", - "sideEffects": false, + "sideEffects": [ + "**/*.css" + ], "files": [ "dist", "LICENSE", @@ -19,7 +21,11 @@ ".": "./src/index.ts", "./canvas": "./src/canvas.ts", "./core": "./src/core.ts", - "./tooltip": "./src/tooltip.tsx" + "./tooltip": "./src/tooltip.tsx", + "./city": "./src/city.ts", + "./city/styles.css": "./src/city/styles.css", + "./city/theme-default.css": "./src/city/theme-default.css", + "./city/theme-tailwind.css": "./src/city/theme-tailwind.css" }, "dependencies": { "@tanstack/charts": "workspace:*" @@ -30,7 +36,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 +58,25 @@ "./tooltip": { "types": "./dist/tooltip.d.ts", "import": "./dist/tooltip.js" + }, + "./city": { + "types": "./dist/city.d.ts", + "import": "./dist/city.js" + }, + "./city/styles.css": { + "default": "./dist/city/styles.css" + }, + "./city/theme-default.css": { + "default": "./dist/city/theme-default.css" + }, + "./city/theme-tailwind.css": { + "default": "./dist/city/theme-tailwind.css" } } + }, + "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..5ea3d7fb --- /dev/null +++ b/packages/react-charts/src/city.test.tsx @@ -0,0 +1,52 @@ +// @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('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/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 ( + + ) +} + +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 ? 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) => ( +
+
+ ))} +
+
+ ) +} +export function CompanyMonitor({ + companies, + matching, + pageSize, + loading, + error, +}) { + const { t, format } = useMapI18n() + const recent = useMemo(() => recentCriticalCompanies(companies), [companies]) + return ( + + ) +} 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..88302f49 --- /dev/null +++ b/packages/react-charts/src/city/engine/city.js @@ -0,0 +1,469 @@ +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 = 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 + 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..8c82f51d --- /dev/null +++ b/packages/react-charts/src/city/engine/model.js @@ -0,0 +1,416 @@ +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, + } + }) +} +export function sizeForValue(value) { + return value < 90 + ? { height: 0.95, floors: 1, house: true } + : value < 550 + ? { height: 2.4, floors: 2, house: false } + : value < 1800 + ? { height: 4.3, floors: 4, house: false } + : { height: 7.2, floors: 6, house: false } +} +// 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, + 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) => ( + + ))} + + ) +} 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/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.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 ( + <> +
+ +
+ {showMonitor && ( + + )} +
+
+ {showCompass && ( + + )} +
+ {showDetails && selected && ( + + )} + {!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]) => ( + + ))} +
+ )} +
+
+ {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) => ( + + ))} + +
+ + )} + {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..1ff2058f --- /dev/null +++ b/packages/react-charts/src/city/mark.js @@ -0,0 +1,63 @@ +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', + 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/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..a894e58e --- /dev/null +++ b/packages/react-charts/src/city/react.jsx @@ -0,0 +1,256 @@ +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 ( + + ) +} +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 ( + <> + +