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..7c4e6ed9 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,25 @@ 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: keep the renderer behind an optional entry and Three.js behind an + optional peer. Own scoped styles in the renderer, as existing React examples + do, and retain the package's sideEffects: false contract. JavaScript runtime + modules currently use an explicit TypeScript public contract. +- Verification: city integration tests and the packed package gate pass. +- Application correction: the first port added a standalone `/city/` route and + browser-check script. The repository already has source-backed catalog cases, + React mounting helpers, definition-only previews, and interaction scenarios. + Move the city example into that existing flow, colocate its regressions with + the package, and remove the separate route, runner, and stylesheet exports. +- Follow-up: consider migrating the private JavaScript engine to TypeScript + independently of the public React API. diff --git a/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md b/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md index 0dd3a650..124b943c 100644 --- a/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md +++ b/benchmarks/conformance/DEFINITION-COVERAGE-AUDIT.md @@ -2,7 +2,7 @@ Date: 2026-08-10 -Scope: all 188 catalog directories. The 67 cases previously classified as +Scope: all 189 catalog directories. The 67 cases previously classified as strict custom authoring, preparation review, or shell-only are reviewed beside the former 42-case definition-native control group from [the custom authoring audit](./CUSTOM-AUTHORING-AUDIT.md). That audit remains @@ -31,12 +31,12 @@ work: | Disposition | Cases | Meaning | | --------------------- | ------: | ------------------------------------------------------------------------ | -| Definition now | 135 | Current marks and eager transforms are sufficient | +| Definition now | 136 | Current marks and eager transforms are sufficient | | First-party primitive | 35 | Add a reusable mark, transform, layout, guide, or controlled signal | | Optional primitive | 15 | Keep a heavy dependency granular, but hide its layout DTOs and lifecycle | | Application boundary | 2 | The remaining work is product state, DOM layout, or data arrival | | Inline custom mark | 1 | The geometry is intentionally case-specific | -| **Total** | **188** | | +| **Total** | **189** | | A normal definition does not require every implementation algorithm to live in Charts core. D3 may implement an optional `sankeyDiagram`, `densityContour`, @@ -161,6 +161,7 @@ ownership boundary. | [Labeled heatmap](./cases/heatmap-labeled/example.tsx) | Definition now | Native cells and text share explicit category and color scales. | | [Histogram](./cases/histogram/example.tsx) | Definition now | Public `binX` output feeds native semantic rectangle intervals. | | [Bubble scatter](./cases/scatter-bubble/example.tsx) | Definition now | Native dot color and radius channels consume model rows directly. | +| [3D city map](./cases/city-map/example.tsx) | Definition now | City marks retain a semantic SVG scene while the React renderer paints the interactive WebGL surface. | ## Statistical and prepared-data cases @@ -353,8 +354,8 @@ bundle. ## Delivery result -All 188 catalog directories now have one roadmap record and case-local -evidence. All 185 normal-definition cases are verified against their current +All 189 catalog directories now have one roadmap record and case-local +evidence. All 186 normal-definition cases are verified against their current boundary; only cases 85, 86, and 116 retain accepted application or bespoke geometry work. The roadmap validator compares its IDs with the live catalog directories so a new case cannot silently remain outside this review. diff --git a/benchmarks/conformance/DEFINITION-COVERAGE-OVERVIEW.md b/benchmarks/conformance/DEFINITION-COVERAGE-OVERVIEW.md index c2a456c1..ae1ab6e9 100644 --- a/benchmarks/conformance/DEFINITION-COVERAGE-OVERVIEW.md +++ b/benchmarks/conformance/DEFINITION-COVERAGE-OVERVIEW.md @@ -2,19 +2,19 @@ Date: 2026-08-10 -The catalog has 188 case directories. 185 visualizations now use +The catalog has 189 case directories. 186 visualizations now use normal chart definitions. Cases 85 and 86 retain application shells, and Case 116 retains one deliberately bespoke inline mark. No other case-owned layout or renderer is an accepted endpoint. | Disposition | Cases | Result | | --------------------- | ------: | ------------------------------------------------- | -| Definition now | 135 | Normal definition | +| Definition now | 136 | Normal definition | | First-party primitive | 35 | Normal definition | | Optional primitive | 15 | Normal definition | | Application boundary | 2 | Accepted boundary | | Inline custom mark | 1 | Accepted boundary | -| **Total** | **188** | **185 normal definitions; 3 accepted boundaries** | +| **Total** | **189** | **186 normal definitions; 3 accepted boundaries** | ## Shared decisions @@ -63,7 +63,7 @@ presentation policy. ## Verification -- The roadmap validator compares all 188 roadmap and audit IDs with the live +- The roadmap validator compares all 189 roadmap and audit IDs with the live catalog directories, requires unique IDs, validates the capability DAG, and requires every verified or accepted case to cite evidence under its own directory. @@ -268,3 +268,4 @@ presentation policy. | [heatmap-labeled — Labeled ordinal heatmap](./cases/heatmap-labeled/example.tsx) | Definition now | `current-definition-api` | `example.tsx` | | [histogram — Histogram](./cases/histogram/example.tsx) | Definition now | `current-definition-api` | `example.tsx` | | [scatter-bubble — Bubble scatterplot](./cases/scatter-bubble/example.tsx) | Definition now | `current-definition-api` | `example.tsx` | +| [city-map — 3D city map](./cases/city-map/example.tsx) | Definition now | `current-definition-api` | `example.tsx`, `case.json`, `tanstack.test.ts` | diff --git a/benchmarks/conformance/cases/city-map/case.json b/benchmarks/conformance/cases/city-map/case.json new file mode 100644 index 00000000..b8877103 --- /dev/null +++ b/benchmarks/conformance/cases/city-map/case.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "order": 1990, + "id": "city-map", + "title": "3D city map", + "family": "geography", + "height": 720, + "intent": "Represent store metrics as building heights and category intersections as districts, with an interactive 3D surface and a semantic top-view preview.", + "support": "composed", + "features": [ + "custom renderer", + "Three.js city", + "metric-driven building height", + "category intersections", + "camera controls", + "selection", + "lighting and weather" + ], + "geometry": [ + { + "role": "rect", + "count": 140 + } + ], + "source": { + "title": "Three.js WebGL renderer", + "url": "https://threejs.org/docs/#api/en/renderers/WebGLRenderer" + }, + "ai": { + "create": "Render store metrics as buildings in category-intersection districts using the city renderer and the existing React chart contracts.", + "maintain": "Preserve datum identity, metric heights, category districts, keyboard selection, camera controls, renderer disposal, and the source-backed catalog entry." + }, + "collections": ["shadcn"] +} diff --git a/benchmarks/conformance/cases/city-map/data.ts b/benchmarks/conformance/cases/city-map/data.ts new file mode 100644 index 00000000..b2338698 --- /dev/null +++ b/benchmarks/conformance/cases/city-map/data.ts @@ -0,0 +1,39 @@ +import type { CityDatum } from '@tanstack/charts/react/city' + +export type District = 1 | 2 | 3 | 4 | 5 | 6 | 7 +export interface Store extends CityDatum { + mask: District +} +export const districtMasks: District[] = [1, 2, 4, 3, 5, 6, 7] +export const districtColors = [ + '#4b91ef', + '#e281af', + '#ecc456', + '#ab7cdd', + '#47b8b2', + '#e99b55', + '#7777ca', +] +const categories = [ + { value: 'retail', label: 'Retail' }, + { value: 'online', label: 'Online' }, + { value: 'wholesale', label: 'Wholesale' }, +] + +export function storesForRevision(revision: number): Store[] { + return Array.from({ length: 140 }, (_, index) => { + const mask = ((index % 7) + 1) as District + return { + id: `store-${index}`, + name: `Store ${String(index + 1).padStart(3, '0')}`, + metric: { + label: 'Orders', + value: ((index * 397 + revision * 71) % 5000) + 100, + }, + categories: categories.filter((_, bit) => mask & (1 << bit)), + mask, + status: + index % 9 === 0 ? 'critical' : index % 5 === 0 ? 'warning' : 'healthy', + } + }) +} diff --git a/benchmarks/conformance/cases/city-map/example.tsx b/benchmarks/conformance/cases/city-map/example.tsx new file mode 100644 index 00000000..22935fef --- /dev/null +++ b/benchmarks/conformance/cases/city-map/example.tsx @@ -0,0 +1,79 @@ +import { useMemo, type CSSProperties } from 'react' +import { defineChart } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { CityMap, cityMark, cityFocus } from '@tanstack/charts/react/city' +import { districtMasks, districtColors, storesForRevision } from './data' +import './styles.css' + +export interface ChartOptions { + revision: number +} +export interface ExampleProps { + width?: number + height?: number + revision?: number +} + +export function createExampleChart(input: ChartOptions) { + return defineChart({ + marks: [cityMark(storesForRevision(input.revision))], + scales: { + x: { scale: scaleLinear, axis: false }, + y: { scale: scaleLinear, axis: false }, + }, + color: { domain: districtMasks, range: districtColors }, + margin: 0, + guides: false, + focus: cityFocus, + }) +} + +export const exampleAriaLabel = 'Store health city' +export const chart = createExampleChart({ revision: 0 }) + +export default function Example({ + width = 960, + height = 720, + revision = 0, +}: ExampleProps) { + const records = useMemo(() => storesForRevision(revision), [revision]) + return ( +
+
+
+
+

City Map - Store Health

+

Orders and store health across category districts

+
+
+
+ +
+ +
+
+ ) +} diff --git a/benchmarks/conformance/cases/city-map/plot.ts b/benchmarks/conformance/cases/city-map/plot.ts new file mode 100644 index 00000000..eebd7c46 --- /dev/null +++ b/benchmarks/conformance/cases/city-map/plot.ts @@ -0,0 +1,34 @@ +import * as Plot from '@observablehq/plot' +import { mountObservablePlot } from '../../shared/mount' +import { districtMasks, districtColors, storesForRevision } from './data' +import { referenceLots } from './transform' +import type { ConformanceInput } from '../../types' + +// Plot compares the semantic district layout. The interactive example paints +// that same data as a Three.js city; Plot does not implement its WebGL surface. +function render(input: ConformanceInput) { + const rows = referenceLots(storesForRevision(input.revision)) + return Plot.plot({ + ariaLabel: 'Store health city', + width: input.width, + height: input.height, + margin: 0, + x: { axis: null, nice: false }, + y: { axis: null, nice: false }, + color: { domain: districtMasks, range: districtColors }, + marks: [ + Plot.rect(rows, { + x1: 'x', + x2: 'x', + y1: 'z', + y2: 'z', + fill: 'mask', + inset: -3, + rx: 1, + }), + ], + }) +} + +export const mount = (container: HTMLElement, input: ConformanceInput) => + mountObservablePlot(container, input, render) diff --git a/benchmarks/conformance/cases/city-map/styles.css b/benchmarks/conformance/cases/city-map/styles.css new file mode 100644 index 00000000..c4428e5d --- /dev/null +++ b/benchmarks/conformance/cases/city-map/styles.css @@ -0,0 +1,162 @@ +.sc-example { + --background: oklch(1 0 0); + --foreground: oklch(0 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --border: oklch(0.922 0 0); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + display: flex; + justify-content: center; + align-items: flex-start; + overflow: hidden; + color: var(--foreground); + background: var(--background); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + text-rendering: geometricPrecision; +} +:root[data-theme='dark'] .sc-example { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --border: oklch(1 0 0 / 10%); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); +} +.sc-example, +.sc-example * { + box-sizing: border-box; +} +.sc-card { + display: flex; + flex-direction: column; + gap: 24px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--card); + color: var(--card-foreground); + padding: 24px 0; +} +.sc-card-header { + display: grid; + gap: 8px; + padding: 0 24px; +} +.sc-card-heading { + display: grid; + gap: 8px; +} +.sc-card-header h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 1; + letter-spacing: -0.01em; +} +.sc-card-header p { + margin: 0; + color: var(--muted-foreground); + font-size: 14px; + line-height: 20px; +} +.sc-card-content { + display: flex; + min-height: 0; + flex-direction: column; + padding: 0 24px; +} +.sc-chart { + position: relative; + flex: none; +} +.sc-chart > * { + display: block; +} +.sc-card-footer { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 0 24px; + font-size: 14px; + line-height: 1; +} +.sc-trend { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} +.sc-trend svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.sc-footer-note { + color: var(--muted-foreground); +} +.sc-example .ts-chart { + color: var(--muted-foreground); +} +.sc-example .ts-chart__grid line, +.sc-example .ts-chart__polar-grid path, +.sc-example .ts-chart__polar-grid line { + stroke: var(--border); +} +.sc-example .ts-chart__axes text, +.sc-example .ts-chart__polar-grid text { + fill: var(--muted-foreground); + font-size: 12px; +} +.sc-example .ts-chart-tooltip { + min-width: 128px; + padding: 7px 10px !important; + border: 1px solid var(--border) !important; + border-radius: 8px !important; + background: var(--card) !important; + color: var(--card-foreground) !important; + box-shadow: 0 2px 6px rgb(0 0 0 / 8%); + font-size: 12px; +} + +.sc-city-map .atlas-city-map, +.sc-city-map .atlas-city-theme-default { + --atlas-city-background: var(--card); + --atlas-city-card: var(--card); + --atlas-city-text: var(--foreground); + --atlas-city-border: var(--border); + --atlas-city-muted: var(--muted-foreground); + --atlas-city-overlay-text: #0f172a; + --atlas-city-surface-tint: var(--card); + --atlas-city-tooltip-text: var(--foreground); + --atlas-city-accent: var(--chart-2); + --atlas-city-font-family: inherit; + --atlas-city-height: inherit; +} +.sc-city-map .atlas-city-map { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} diff --git a/benchmarks/conformance/cases/city-map/tanstack.test.ts b/benchmarks/conformance/cases/city-map/tanstack.test.ts new file mode 100644 index 00000000..6d53d56a --- /dev/null +++ b/benchmarks/conformance/cases/city-map/tanstack.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { createChartRuntime } from '@tanstack/charts/runtime' +import { createExampleChart } from './example' +import { storesForRevision } from './data' +import type { Store } from './data' +import { referenceLots } from './transform' + +describe('city catalog definition', () => { + it('preserves the public datum contract and matches the independent top-view layout', () => { + const scene = createChartRuntime().render( + createExampleChart({ revision: 0 }), + { width: 640, height: 480 }, + ) + const expected = referenceLots(storesForRevision(0)) + expect(scene.points).toHaveLength(140) + expect( + scene.points.map((point) => [point.datum.id, point.xValue, point.yValue]), + ).toEqual(expected.map((row) => [row.id, row.x, row.z])) + expect(scene.points[0]?.datum.metric.label).toBe('Orders') + }) +}) diff --git a/benchmarks/conformance/cases/city-map/tanstack.ts b/benchmarks/conformance/cases/city-map/tanstack.ts new file mode 100644 index 00000000..b865b252 --- /dev/null +++ b/benchmarks/conformance/cases/city-map/tanstack.ts @@ -0,0 +1,67 @@ +import { createElement, forwardRef, useImperativeHandle, useRef } from 'react' +import Example, { createExampleChart, exampleAriaLabel } from './example' +import { reactMount } from '../../shared/react-mount' +import { tanstackExampleMount } from '../../shared/mount' +import type { ReactConformanceProps } from '../../shared/react-mount' +import type { ConformanceTestDriver } from '../../types' + +export * from './example' + +const ConformanceExample = forwardRef< + ConformanceTestDriver, + ReactConformanceProps +>(function ConformanceExample({ input }, ref) { + const root = useRef(null) + useImperativeHandle(ref, () => ({ + resolveTarget(target) { + const selector = + target.anchor === 'top-view' + ? '.atlas-city-perspective-button:nth-of-type(2)' + : target.anchor === 'isometric-view' + ? '.atlas-city-perspective-button:nth-of-type(1)' + : target.anchor === 'controls' + ? '.atlas-city-camera-button--visibility' + : null + const element = selector + ? root.current?.querySelector(selector) + : null + if (!element) return null + const box = element.getBoundingClientRect() + return { + x: box.left + box.width / 2, + y: box.top + box.height / 2, + focusElement: element, + } + }, + readState() { + const buttons = root.current?.querySelectorAll( + '.atlas-city-perspective-button', + ) + return { + view: + buttons?.[1]?.getAttribute('aria-pressed') === 'true' ? 'top' : 'iso', + canvasCount: root.current?.querySelectorAll('canvas').length ?? 0, + controlsVisible: + root.current?.querySelector('.atlas-city-scene-controls') + ?.dataset.controlsVisible === 'true', + } + }, + })) + return createElement( + 'div', + { ref: root, 'data-conformance-view': 'main' }, + createElement(Example, { + width: input.width, + height: input.height, + revision: input.revision, + }), + ) +}) + +export const mount = reactMount(ConformanceExample) +// The existing SVG scene is the compact semantic top view, as with other +// application examples that provide a definition-only catalog preview. +export const catalogCase = tanstackExampleMount( + createExampleChart, + exampleAriaLabel, +) diff --git a/benchmarks/conformance/cases/city-map/transform.ts b/benchmarks/conformance/cases/city-map/transform.ts new file mode 100644 index 00000000..25fe3714 --- /dev/null +++ b/benchmarks/conformance/cases/city-map/transform.ts @@ -0,0 +1,114 @@ +import { districtMasks } from './data' +import type { District, Store } from './data' + +interface Bounds { + x1: number + x2: number + z1: number + z2: number +} +interface Lot { + x: number + z: number + col: number + row: number + mask: District +} +type Sizes = Record<'cx' | 'cz' | 'a' | 'b' | 'c' | 'ab' | 'ac' | 'bc', number> +const expansion: Record, keyof Sizes> = { + 1: 'a', + 2: 'b', + 4: 'c', + 3: 'ab', + 5: 'ac', + 6: 'bc', +} + +export function referenceLots(records: readonly Store[]) { + const needed = new Map( + districtMasks.map((mask) => [ + mask, + records.filter((row) => row.mask === mask).length, + ]), + ) + const core = Math.max( + 1, + Math.ceil(Math.sqrt(Math.max(0, ...needed.values())) / 2), + ) + const sizes: Sizes = { + cx: core, + cz: core, + a: 1, + b: 1, + c: 1, + ab: 1, + ac: 1, + bc: 1, + } + let buckets = new Map() + for (let attempt = 0; attempt < 250; attempt++) { + const p = sizes + const a: Bounds = { + x1: -p.cx - p.ab, + x2: p.cx + p.ac, + z1: -p.cz - p.a, + z2: p.cz, + } + const b: Bounds = { + x1: -p.cx - p.ab - p.b, + x2: p.cx, + z1: -p.cz, + z2: p.cz + p.bc, + } + const c: Bounds = { + x1: -p.cx, + x2: p.cx + p.ac + p.c, + z1: -p.cz, + z2: p.cz + p.bc, + } + buckets = new Map(districtMasks.map((mask) => [mask, []])) + for (let row = a.z1; row <= b.z2; row++) { + for (let col = b.x1; col <= c.x2; col++) { + const inside = (bounds: Bounds) => + col >= bounds.x1 && + col <= bounds.x2 && + row >= bounds.z1 && + row <= bounds.z2 + const mask = (Number(inside(a)) | + (Number(inside(b)) << 1) | + (Number(inside(c)) << 2)) as District | 0 + const park = + ((Math.imul(col + 59, 7349) ^ Math.imul(row + 41, 9157)) >>> 0) % + 100 < + 9 + if (mask && col % 4 !== 0 && row % 4 !== 0 && !park) { + buckets + .get(mask)! + .push({ x: col * 2.8, z: row * 2.8, col, row, mask }) + } + } + } + const missing = districtMasks.filter( + (mask) => buckets.get(mask)!.length < needed.get(mask)!, + ) + if (!missing.length) break + for (const mask of missing) { + if (mask === 7) { + sizes.cx++ + sizes.cz++ + } else sizes[expansion[mask]]++ + } + } + for (const lots of buckets.values()) { + lots.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, + ) + } + return records.map((record) => ({ + ...record, + ...buckets.get(record.mask)!.shift()!, + })) +} diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json index 721ec1ca..ad7cadc3 100644 --- a/benchmarks/conformance/catalog-index.json +++ b/benchmarks/conformance/catalog-index.json @@ -12407,6 +12407,43 @@ "entries": { "example": "benchmarks/conformance/cases/197-shadcn-tooltip-label-none/example.tsx" } + }, + { + "schemaVersion": 1, + "collections": ["shadcn"], + "height": 720, + "order": 1990, + "id": "city-map", + "title": "3D city map", + "family": "geography", + "intent": "Represent store metrics as building heights and category intersections as districts, with an interactive 3D surface and a semantic top-view preview.", + "support": "composed", + "features": [ + "custom renderer", + "Three.js city", + "metric-driven building height", + "category intersections", + "camera controls", + "selection", + "lighting and weather" + ], + "geometry": [ + { + "role": "rect", + "count": 140 + } + ], + "source": { + "title": "Three.js WebGL renderer", + "url": "https://threejs.org/docs/#api/en/renderers/WebGLRenderer" + }, + "ai": { + "create": "Render store metrics as buildings in category-intersection districts using the city renderer and the existing React chart contracts.", + "maintain": "Preserve datum identity, metric heights, category districts, keyboard selection, camera controls, renderer disposal, and the source-backed catalog entry." + }, + "entries": { + "example": "benchmarks/conformance/cases/city-map/example.tsx" + } } ] } diff --git a/benchmarks/conformance/definition-coverage-roadmap.json b/benchmarks/conformance/definition-coverage-roadmap.json index 793a2a3b..0386a5cd 100644 --- a/benchmarks/conformance/definition-coverage-roadmap.json +++ b/benchmarks/conformance/definition-coverage-roadmap.json @@ -6315,6 +6315,37 @@ "summary": "Feed model rows directly to a native dot mark with color and radius channels plus ordinary legends and scales." } ] + }, + { + "id": "city-map", + "coverage": "app-composed", + "disposition": "definition-now", + "phase": "phase-0", + "status": "verified", + "capabilities": ["current-definition-api"], + "evidence": [ + "cases/city-map/example.tsx", + "cases/city-map/tanstack.test.ts", + "cases/city-map/case.json" + ], + "work": [ + { + "kind": "definition-composition", + "stage": "definition-builder", + "owner": "charts", + "coordinateSpace": "none", + "sources": ["example.tsx"], + "summary": "Compose the city mark with explicit scales, district color, and the existing renderer and semantic scene contracts." + }, + { + "kind": "application-shell", + "stage": "post-render", + "owner": "application", + "coordinateSpace": "dom", + "sources": ["example.tsx"], + "summary": "Mount the public React city example through the shared catalog lifecycle and interaction helpers." + } + ] } ], "overview": "DEFINITION-COVERAGE-OVERVIEW.md" diff --git a/benchmarks/conformance/definition-coverage-roadmap.test.ts b/benchmarks/conformance/definition-coverage-roadmap.test.ts index 225b783d..77845469 100644 --- a/benchmarks/conformance/definition-coverage-roadmap.test.ts +++ b/benchmarks/conformance/definition-coverage-roadmap.test.ts @@ -140,12 +140,12 @@ describe('definition coverage roadmap', () => { .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) - expect(auditedIds).toHaveLength(188) - expect(new Set(auditedIds).size).toBe(188) - expect(roadmapIds).toHaveLength(188) - expect(new Set(roadmapIds).size).toBe(188) - expect(overviewIds).toHaveLength(188) - expect(new Set(overviewIds).size).toBe(188) + expect(auditedIds).toHaveLength(189) + expect(new Set(auditedIds).size).toBe(189) + expect(roadmapIds).toHaveLength(189) + expect(new Set(roadmapIds).size).toBe(189) + expect(overviewIds).toHaveLength(189) + expect(new Set(overviewIds).size).toBe(189) expect([...roadmapIds].sort()).toEqual([...auditedIds].sort()) expect([...roadmapIds].sort()).toEqual([...overviewIds].sort()) expect([...roadmapIds].sort()).toEqual([...catalogIds].sort()) @@ -154,13 +154,13 @@ describe('definition coverage roadmap', () => { expect(countBy(roadmap.cases, 'disposition')).toEqual({ 'application-boundary': 2, - 'definition-now': 135, + 'definition-now': 136, 'first-party-primitive': 35, 'inline-custom-mark': 1, 'optional-primitive': 15, }) expect(countBy(roadmap.cases, 'phase')).toEqual({ - 'phase-0': 137, + 'phase-0': 138, 'phase-1': 2, 'phase-2': 26, 'phase-3': 11, @@ -170,8 +170,8 @@ describe('definition coverage roadmap', () => { const roadmapById = new Map(roadmap.cases.map((entry) => [entry.id, entry])) const auditRows = coverageRows(audit, false) const overviewRows = coverageRows(overview, true) - expect(auditRows).toHaveLength(188) - expect(overviewRows).toHaveLength(188) + expect(auditRows).toHaveLength(189) + expect(overviewRows).toHaveLength(189) for (const row of auditRows) { expect(dispositionByLabel[row.disposition]).toBe( roadmapById.get(row.id)?.disposition, diff --git a/benchmarks/conformance/previews/city-map.svg b/benchmarks/conformance/previews/city-map.svg new file mode 100644 index 00000000..9dc9681e --- /dev/null +++ b/benchmarks/conformance/previews/city-map.svg @@ -0,0 +1 @@ + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 2e356676..0075e4ac 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "4c9a63949cbf9511cf8abd901c2bb8224ea959d284456a9b90e1b87745400ef9", + "sourceHash": "7712eda7fb39355f9096c508d5ef23cc63555b5931a472fb56a9440a37d8c93e", "assets": [ { "id": "01-line-gaps", @@ -943,6 +943,11 @@ "id": "197-shadcn-tooltip-label-none", "sha256": "4968486648c607560e8b47274b3fcd628134c2f9453a0d09d9362861e0d011bf", "bytes": 5019 + }, + { + "id": "city-map", + "sha256": "37634ee03b48edd668ac6c1732d7d29c3ffa985edd873d6aa0df6fbac48750c5", + "bytes": 19593 } ] } diff --git a/docs/config.json b/docs/config.json index 87ed2e2d..43a735a9 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1071,6 +1071,10 @@ { "label": "shadcn Tooltip - No Label", "to": "/charts/catalog/charts/197-shadcn-tooltip-label-none/" + }, + { + "label": "3D city map", + "to": "/charts/catalog/charts/city-map/" } ] }, @@ -1311,6 +1315,10 @@ "label": "Geo Shape Mark", "to": "reference/marks/geo" }, + { + "label": "City Map Renderer", + "to": "reference/marks/city" + }, { "label": "Polar Marks", "to": "reference/marks/polar" diff --git a/docs/examples/maps-and-spatial.md b/docs/examples/maps-and-spatial.md index ede6a4a9..66407e51 100644 --- a/docs/examples/maps-and-spatial.md +++ b/docs/examples/maps-and-spatial.md @@ -272,3 +272,12 @@ the controlled gesture loop. - Verify labels and legends at narrow widths. - Provide a non-spatial exact-value path through [Accessibility](../guides/accessibility.md). + +## Category districts and metric-driven buildings + +The city renderer encodes records as buildings: height follows a nonnegative +metric and districts represent category intersections. The example includes +camera controls, selection, lighting, and weather. See the +[City Map Renderer reference](../reference/marks/city.md). + + diff --git a/docs/framework/react/adapter.md b/docs/framework/react/adapter.md index 8a128a05..16d018d5 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](../../reference/marks/city.md) for metric-driven buildings, categorical districts, and React controls. diff --git a/docs/reference/marks/city.md b/docs/reference/marks/city.md new file mode 100644 index 00000000..896d6518 --- /dev/null +++ b/docs/reference/marks/city.md @@ -0,0 +1,153 @@ +--- +title: City Map Renderer +description: Render metric-driven buildings with categorical districts, selection, camera controls, lighting, and weather in React. +--- + +The city map represents records as buildings. Building height encodes a nonnegative +metric, districts encode intersections of up to three categories, and health status +controls the building effects. It supports orbit, pan, zoom, selection, search, +district labels, day/night lighting, rain, snow, and reduced motion. + +The city module is optional. Ordinary chart imports do not load Three.js or city +styles. Install `three@^0.180.0` alongside React and TanStack Charts, then import +the city entry in your client component: + +```tsx +'use client' + +import { CityMap, type CityDatum } from '@tanstack/charts/react/city' + +const records: CityDatum[] = [ + { + id: 'store-1', + name: 'North store', + metric: { label: 'Orders', value: 1200 }, + categories: [{ label: 'Retail', value: 'retail' }], + status: 'healthy', + }, + { + id: 'store-2', + name: 'South store', + metric: { label: 'Orders', value: 700 }, + categories: [{ label: 'Retail', value: 'retail' }], + status: 'critical', + }, +] + +export default function Stores() { + return ( + + ) +} +``` + +The compatibility entry is `@tanstack/react-charts/city`. The component owns its +scoped styles, following the renderer pattern used by the other chart examples. + +Each row needs a unique string or numeric `id`, a `name`, a metric with a nonempty +label and a finite nonnegative value, and a `status` of `healthy`, `warning`, or +`critical`. Supply one to three `{ value, label }` categories per row, with consistent +labels across rows. There may be at most three distinct categories in the dataset. +Alternatively, supply a district `mask` from 1 to 7. Empty data is supported. +Optional `updatedAt` and `statusChangedAt` fields accept ISO 8601 timestamps with +a timezone. Rows are never mutated; selection returns the original datum. + +## Building height + +Buildings use a continuous square-root scale adapted to the dataset's metric +values. The smallest value maps to a one-floor house and the largest to a +14-floor building (0.95–16.8 scene units). Intermediate heights use +`sqrt((value - min) / (max - min))`, clamped to 0–1, to interpolate between those +heights. This gives smaller and intermediate values more presence than a linear +scale when large values dominate. Height is not proportional to the metric: +twice the height does not mean twice the value. + +Equal values always produce equal heights. If all values are equal, including a +single record or all zeros, buildings use the midpoint height (8.875 scene units). +The scale is shared across districts and recalculated when the data changes. +Search and selection preserve the scale. Separate datasets have independent +scales, so heights should be compared within the same dataset. + +When supplying a precomputed `CityLayout`, set its optional `metricDomain` to the +full dataset's `[min, max]` to keep heights stable across application-owned +filters or pagination. Otherwise, the domain is derived from the layout's rows. +Changes to this domain rebuild heights even if the visible metric values have +not changed. Exact metric values remain available in tooltips and details. + +## Composition + +`CityMap` includes the controls, search, details, and critical-record monitor. +`CityChart` accepts a memoized `defineCityChart(data)` definition and renders the +surface, tooltip, and optional district labels. Its selection callback uses the +usual TanStack `ChartPoint`; `CityMap.onSelect` receives the datum instead. +`cityMark`, `cityFocus`, and `createCityRenderer` are also exported for composition +with the existing custom renderer host. Use one city mark per renderer surface. +Definitions can be shared across mounted charts; each mount owns its camera, +WebGL context, focus resolver, and resource lifetime. + +## Controls and appearance + +Use `view="iso"` or `view="top"`, `weather="none" | "rain" | "snow"`, and +`lightingMode="auto" | "day" | "twilight" | "night"`. Automatic lighting follows +the local clock. Weather is supplied by the application; the package makes no +weather or data requests. + +`life`, `effects`, `autoRotate`, and `showDistrictLabels` have optional change +callbacks for controlled use. `showControls`, `showSearch`, `showDetails`, +`showMonitor`, `showCompass`, and `showInstructions` control the included UI. +`showEnvironmentControls` enables the lighting and weather selectors. +`busy` disables interactions while the application updates its data. + +Pass a `CityChartHandle` ref for `zoom`, `view`, `select`, `deselect`, `cancel`, +`capture`, `play`, `scrollIntoView`, and `stats`. Selection accepts an options bag: +`ref.current?.select(record, { focus: true, maxZoom: true })`. + +The renderer supplies scoped theme variables. Override `--atlas-city-*` properties +in your own CSS, or set `theme={null}` to provide all variables yourself. The map +needs a measurable container width and reserves at least 520 pixels of height. + +## Rendering and accessibility + +Use a client boundary for React Server Components. Server rendering outputs the +standard semantic SVG top view; WebGL paints above it after mounting in a browser. +The SVG remains available when WebGL cannot initialize. +WebGL initialization or context loss produces a visible error message. The map +honors reduced motion, exposes keyboard focus/selection through the chart host, +and supports `+`/`-` for zoom, `0` for the isometric view, and Escape to clear +selection. Controls and descriptions follow the browser language (`en-US` and +`pt-BR`; other languages use English). + +Dispose charts by unmounting them. Unmounting releases observers, listeners, +animations, geometry, materials, textures, and the WebGL renderer. + +## Component and engine contracts + +| Type | Contract | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `CityMapProps` | Full-map data, controlled settings, visibility flags, datum selection, and ref. | +| `CityChartProps` | Definition, selected datum, renderer settings, callbacks, and render-prop children. | +| `CityChartState` | Render-prop state containing readiness, localized error, environment controls, and weather override. | +| `CityDefinitionOptions` | Definition options plus an optional precomputed city layout. | +| `CityLayout` | Positioned copies of the input rows, district bounds and anchors, road bounds, and city center. | +| `CityEngine` | Mounted renderer service passed to lifecycle callbacks; owns activity, view, zoom, focus, selection, data, and disposal. | +| `CityRendererOptions` | Initial view/activity settings and mount, data, destroy, error, hover, projection, and frame callbacks. | +| `CityStats` | Draw calls, triangles, point count, lighting, and weather diagnostics returned by the handle. | +| `CityView` | Isometric (`iso`) or overhead (`top`) camera view. | +| `CityWeather` | Application-supplied `none`, `rain`, or `snow` effect. | +| `CityLighting` | Local-clock `auto` mode, or fixed `day`, `twilight`, or `night`. | +| `EnvironmentControlsProps` | Controlled lighting/weather values, change callbacks, and an optional disabled state. | + +`EnvironmentControls` renders the lighting and weather selectors independently of +the full map. Its callbacks receive the next mode. `CityRendererOptions.onData` +receives the mounted engine and `{ resetView }`; `hover` receives the datum and an +optional `{ event }` context. Other lifecycle callbacks receive one engine or error. + +## Catalog example + +See the [3D city map](https://tanstack.com/charts/catalog/charts/city-map/) for the live example +and its complete source. Run `pnpm dev:conformance` to use the catalog locally. +The compact preview shows the same definition as a semantic top view. + +The shared conformance runner checks the SVG district layout against an isolated +Plot reference. The live catalog mounts the full WebGL map. Both paths use the +same source data and standard case metadata. diff --git a/examples/conformance/src/main.tsx b/examples/conformance/src/main.tsx index 9083c2c7..34234345 100644 --- a/examples/conformance/src/main.tsx +++ b/examples/conformance/src/main.tsx @@ -204,7 +204,7 @@ function ShadcnCollectionPage({ ) useDocumentMeta( 'shadcn charts · TanStack Charts', - 'TanStack Charts implementations of the official shadcn chart catalog.', + 'Charts styled with shadcn/ui, including the official chart examples.', ) useEffect(() => { @@ -219,7 +219,10 @@ function ShadcnCollectionPage({

shadcn charts

-

{allEntries.length} examples, rebuilt with TanStack Charts.

+

+ {allEntries.length} examples styled with shadcn/ui and TanStack + Charts. +

npm install @tanstack/charts @@ -1309,6 +1312,7 @@ function collectionCases(collectionId: string): ConformanceCaseMeta[] { 'radar', 'radial', 'tooltip', + 'geography', ] return conformanceCases .filter((entry) => entry.collections?.includes(collectionId)) diff --git a/llms.txt b/llms.txt index 4fca1560..f2eb4eba 100644 --- a/llms.txt +++ b/llms.txt @@ -108,6 +108,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam - docs/reference/marks/rules-links-arrows-vectors-and-ticks.md: Rules, Links, Arrows, Vectors, and Ticks: Reference for ruleX, ruleY, link, arrow, vector, tickX, and tickY geometry, channels, defaults, curves, and focus anchors. - docs/reference/marks/text-frame-and-facet.md: Text, Frame, and Facet Marks: Reference for data labels, chart frames, responsive small multiples, shared or per-cell axes, labels, and facet constraints. - docs/reference/marks/geo.md: Geo Shape Mark: Reference for projecting GeoJSON with an opt-in D3-backed geoShape mark. +- docs/reference/marks/city.md: City Map Renderer: Render metric-driven buildings with categorical districts, selection, camera controls, lighting, and weather in React. - docs/reference/marks/polar.md: Polar Marks: Reference for the opt-in polar container, radial marks, guides, injected scale factories, and D3-backed geometry. Authoring rules: diff --git a/package.json b/package.json index 8d00f8a3..a48dd570 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "vite": "^8.0.16", "vite-plugin-solid": "^2.11.12", "vitest": "^4.1.9", - "world-atlas": "2.0.2" + "world-atlas": "2.0.2", + "three": "0.180.0" } } diff --git a/packages/charts-core/docs/config.json b/packages/charts-core/docs/config.json index 87ed2e2d..43a735a9 100644 --- a/packages/charts-core/docs/config.json +++ b/packages/charts-core/docs/config.json @@ -1071,6 +1071,10 @@ { "label": "shadcn Tooltip - No Label", "to": "/charts/catalog/charts/197-shadcn-tooltip-label-none/" + }, + { + "label": "3D city map", + "to": "/charts/catalog/charts/city-map/" } ] }, @@ -1311,6 +1315,10 @@ "label": "Geo Shape Mark", "to": "reference/marks/geo" }, + { + "label": "City Map Renderer", + "to": "reference/marks/city" + }, { "label": "Polar Marks", "to": "reference/marks/polar" diff --git a/packages/charts-core/docs/examples/maps-and-spatial.md b/packages/charts-core/docs/examples/maps-and-spatial.md index ede6a4a9..66407e51 100644 --- a/packages/charts-core/docs/examples/maps-and-spatial.md +++ b/packages/charts-core/docs/examples/maps-and-spatial.md @@ -272,3 +272,12 @@ the controlled gesture loop. - Verify labels and legends at narrow widths. - Provide a non-spatial exact-value path through [Accessibility](../guides/accessibility.md). + +## Category districts and metric-driven buildings + +The city renderer encodes records as buildings: height follows a nonnegative +metric and districts represent category intersections. The example includes +camera controls, selection, lighting, and weather. See the +[City Map Renderer reference](../reference/marks/city.md). + + diff --git a/packages/charts-core/docs/framework/react/adapter.md b/packages/charts-core/docs/framework/react/adapter.md index 8a128a05..16d018d5 100644 --- a/packages/charts-core/docs/framework/react/adapter.md +++ b/packages/charts-core/docs/framework/react/adapter.md @@ -275,3 +275,7 @@ The adapter does not redefine: Use [Scales](../../concepts/scales-and-d3.md) for the injected primitive boundary and the [core reference](../../reference/index.md) for those APIs. + +## 3D city map + +See the [optional city map](../../reference/marks/city.md) for metric-driven buildings, categorical districts, and React controls. diff --git a/packages/charts-core/docs/reference/marks/city.md b/packages/charts-core/docs/reference/marks/city.md new file mode 100644 index 00000000..896d6518 --- /dev/null +++ b/packages/charts-core/docs/reference/marks/city.md @@ -0,0 +1,153 @@ +--- +title: City Map Renderer +description: Render metric-driven buildings with categorical districts, selection, camera controls, lighting, and weather in React. +--- + +The city map represents records as buildings. Building height encodes a nonnegative +metric, districts encode intersections of up to three categories, and health status +controls the building effects. It supports orbit, pan, zoom, selection, search, +district labels, day/night lighting, rain, snow, and reduced motion. + +The city module is optional. Ordinary chart imports do not load Three.js or city +styles. Install `three@^0.180.0` alongside React and TanStack Charts, then import +the city entry in your client component: + +```tsx +'use client' + +import { CityMap, type CityDatum } from '@tanstack/charts/react/city' + +const records: CityDatum[] = [ + { + id: 'store-1', + name: 'North store', + metric: { label: 'Orders', value: 1200 }, + categories: [{ label: 'Retail', value: 'retail' }], + status: 'healthy', + }, + { + id: 'store-2', + name: 'South store', + metric: { label: 'Orders', value: 700 }, + categories: [{ label: 'Retail', value: 'retail' }], + status: 'critical', + }, +] + +export default function Stores() { + return ( + + ) +} +``` + +The compatibility entry is `@tanstack/react-charts/city`. The component owns its +scoped styles, following the renderer pattern used by the other chart examples. + +Each row needs a unique string or numeric `id`, a `name`, a metric with a nonempty +label and a finite nonnegative value, and a `status` of `healthy`, `warning`, or +`critical`. Supply one to three `{ value, label }` categories per row, with consistent +labels across rows. There may be at most three distinct categories in the dataset. +Alternatively, supply a district `mask` from 1 to 7. Empty data is supported. +Optional `updatedAt` and `statusChangedAt` fields accept ISO 8601 timestamps with +a timezone. Rows are never mutated; selection returns the original datum. + +## Building height + +Buildings use a continuous square-root scale adapted to the dataset's metric +values. The smallest value maps to a one-floor house and the largest to a +14-floor building (0.95–16.8 scene units). Intermediate heights use +`sqrt((value - min) / (max - min))`, clamped to 0–1, to interpolate between those +heights. This gives smaller and intermediate values more presence than a linear +scale when large values dominate. Height is not proportional to the metric: +twice the height does not mean twice the value. + +Equal values always produce equal heights. If all values are equal, including a +single record or all zeros, buildings use the midpoint height (8.875 scene units). +The scale is shared across districts and recalculated when the data changes. +Search and selection preserve the scale. Separate datasets have independent +scales, so heights should be compared within the same dataset. + +When supplying a precomputed `CityLayout`, set its optional `metricDomain` to the +full dataset's `[min, max]` to keep heights stable across application-owned +filters or pagination. Otherwise, the domain is derived from the layout's rows. +Changes to this domain rebuild heights even if the visible metric values have +not changed. Exact metric values remain available in tooltips and details. + +## Composition + +`CityMap` includes the controls, search, details, and critical-record monitor. +`CityChart` accepts a memoized `defineCityChart(data)` definition and renders the +surface, tooltip, and optional district labels. Its selection callback uses the +usual TanStack `ChartPoint`; `CityMap.onSelect` receives the datum instead. +`cityMark`, `cityFocus`, and `createCityRenderer` are also exported for composition +with the existing custom renderer host. Use one city mark per renderer surface. +Definitions can be shared across mounted charts; each mount owns its camera, +WebGL context, focus resolver, and resource lifetime. + +## Controls and appearance + +Use `view="iso"` or `view="top"`, `weather="none" | "rain" | "snow"`, and +`lightingMode="auto" | "day" | "twilight" | "night"`. Automatic lighting follows +the local clock. Weather is supplied by the application; the package makes no +weather or data requests. + +`life`, `effects`, `autoRotate`, and `showDistrictLabels` have optional change +callbacks for controlled use. `showControls`, `showSearch`, `showDetails`, +`showMonitor`, `showCompass`, and `showInstructions` control the included UI. +`showEnvironmentControls` enables the lighting and weather selectors. +`busy` disables interactions while the application updates its data. + +Pass a `CityChartHandle` ref for `zoom`, `view`, `select`, `deselect`, `cancel`, +`capture`, `play`, `scrollIntoView`, and `stats`. Selection accepts an options bag: +`ref.current?.select(record, { focus: true, maxZoom: true })`. + +The renderer supplies scoped theme variables. Override `--atlas-city-*` properties +in your own CSS, or set `theme={null}` to provide all variables yourself. The map +needs a measurable container width and reserves at least 520 pixels of height. + +## Rendering and accessibility + +Use a client boundary for React Server Components. Server rendering outputs the +standard semantic SVG top view; WebGL paints above it after mounting in a browser. +The SVG remains available when WebGL cannot initialize. +WebGL initialization or context loss produces a visible error message. The map +honors reduced motion, exposes keyboard focus/selection through the chart host, +and supports `+`/`-` for zoom, `0` for the isometric view, and Escape to clear +selection. Controls and descriptions follow the browser language (`en-US` and +`pt-BR`; other languages use English). + +Dispose charts by unmounting them. Unmounting releases observers, listeners, +animations, geometry, materials, textures, and the WebGL renderer. + +## Component and engine contracts + +| Type | Contract | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `CityMapProps` | Full-map data, controlled settings, visibility flags, datum selection, and ref. | +| `CityChartProps` | Definition, selected datum, renderer settings, callbacks, and render-prop children. | +| `CityChartState` | Render-prop state containing readiness, localized error, environment controls, and weather override. | +| `CityDefinitionOptions` | Definition options plus an optional precomputed city layout. | +| `CityLayout` | Positioned copies of the input rows, district bounds and anchors, road bounds, and city center. | +| `CityEngine` | Mounted renderer service passed to lifecycle callbacks; owns activity, view, zoom, focus, selection, data, and disposal. | +| `CityRendererOptions` | Initial view/activity settings and mount, data, destroy, error, hover, projection, and frame callbacks. | +| `CityStats` | Draw calls, triangles, point count, lighting, and weather diagnostics returned by the handle. | +| `CityView` | Isometric (`iso`) or overhead (`top`) camera view. | +| `CityWeather` | Application-supplied `none`, `rain`, or `snow` effect. | +| `CityLighting` | Local-clock `auto` mode, or fixed `day`, `twilight`, or `night`. | +| `EnvironmentControlsProps` | Controlled lighting/weather values, change callbacks, and an optional disabled state. | + +`EnvironmentControls` renders the lighting and weather selectors independently of +the full map. Its callbacks receive the next mode. `CityRendererOptions.onData` +receives the mounted engine and `{ resetView }`; `hover` receives the datum and an +optional `{ event }` context. Other lifecycle callbacks receive one engine or error. + +## Catalog example + +See the [3D city map](https://tanstack.com/charts/catalog/charts/city-map/) for the live example +and its complete source. Run `pnpm dev:conformance` to use the catalog locally. +The compact preview shows the same definition as a semantic top view. + +The shared conformance runner checks the SVG district layout against an isolated +Plot reference. The live catalog mounts the full WebGL map. Both paths use the +same source data and standard case metadata. diff --git a/packages/charts-core/llms.txt b/packages/charts-core/llms.txt index 4fca1560..f2eb4eba 100644 --- a/packages/charts-core/llms.txt +++ b/packages/charts-core/llms.txt @@ -108,6 +108,7 @@ Read the canonical pages below. Each concept is documented once; guides and exam - docs/reference/marks/rules-links-arrows-vectors-and-ticks.md: Rules, Links, Arrows, Vectors, and Ticks: Reference for ruleX, ruleY, link, arrow, vector, tickX, and tickY geometry, channels, defaults, curves, and focus anchors. - docs/reference/marks/text-frame-and-facet.md: Text, Frame, and Facet Marks: Reference for data labels, chart frames, responsive small multiples, shared or per-cell axes, labels, and facet constraints. - docs/reference/marks/geo.md: Geo Shape Mark: Reference for projecting GeoJSON with an opt-in D3-backed geoShape mark. +- docs/reference/marks/city.md: City Map Renderer: Render metric-driven buildings with categorical districts, selection, camera controls, lighting, and weather in React. - docs/reference/marks/polar.md: Polar Marks: Reference for the opt-in polar container, radial marks, guides, injected scale factories, and D3-backed geometry. Authoring rules: diff --git a/packages/charts-core/package.json b/packages/charts-core/package.json index 74dcfef5..61d50640 100644 --- a/packages/charts-core/package.json +++ b/packages/charts-core/package.json @@ -55,7 +55,8 @@ "react-native-svg": ">=15.15.4 <16", "solid-js": ">=1.8", "svelte": "^5.20.0", - "vue": ">=3.5" + "vue": ">=3.5", + "three": "^0.180.0" }, "peerDependenciesMeta": { "@angular/core": { @@ -96,6 +97,9 @@ }, "vue": { "optional": true + }, + "three": { + "optional": true } }, "devDependencies": { @@ -225,7 +229,8 @@ "./violin": "./src/violin.ts", "./view": "./src/view.ts", "./vue": "./src/vue.ts", - "./waffle": "./src/waffle.ts" + "./waffle": "./src/waffle.ts", + "./react/city": "./src/react-city.ts" }, "publishConfig": { "access": "public", @@ -706,6 +711,10 @@ "./waffle": { "types": "./dist/waffle.d.ts", "import": "./dist/waffle.js" + }, + "./react/city": { + "types": "./dist/react/city.d.ts", + "import": "./dist/react/city.js" } } } diff --git a/packages/charts-core/src/react-city.ts b/packages/charts-core/src/react-city.ts new file mode 100644 index 00000000..0d113bd3 --- /dev/null +++ b/packages/charts-core/src/react-city.ts @@ -0,0 +1,3 @@ +'use client' + +export * from '../../react-charts/src/city' diff --git a/packages/react-charts/package.json b/packages/react-charts/package.json index 15c1b24c..4403dbe1 100644 --- a/packages/react-charts/package.json +++ b/packages/react-charts/package.json @@ -19,7 +19,8 @@ ".": "./src/index.ts", "./canvas": "./src/canvas.ts", "./core": "./src/core.ts", - "./tooltip": "./src/tooltip.tsx" + "./tooltip": "./src/tooltip.tsx", + "./city": "./src/city.ts" }, "dependencies": { "@tanstack/charts": "workspace:*" @@ -30,7 +31,8 @@ }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "react-dom": "^18.0.0 || ^19.0.0", + "three": "^0.180.0" }, "publishConfig": { "access": "public", @@ -51,7 +53,16 @@ "./tooltip": { "types": "./dist/tooltip.d.ts", "import": "./dist/tooltip.js" + }, + "./city": { + "types": "./dist/city.d.ts", + "import": "./dist/city.js" } } + }, + "peerDependenciesMeta": { + "three": { + "optional": true + } } } diff --git a/packages/react-charts/src/city.test.tsx b/packages/react-charts/src/city.test.tsx new file mode 100644 index 00000000..af527219 --- /dev/null +++ b/packages/react-charts/src/city.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment node +import * as React from 'react' +import { fileURLToPath } from 'node:url' +import { renderToString } from 'react-dom/server' +import { build } from 'esbuild' +import { describe, expect, it } from 'vitest' +import { CityMap, CityChart, defineCityChart, type CityDatum } from './city' + +const rows: CityDatum[] = [ + { + id: 7, + name: 'Store', + metric: { label: 'Orders', value: 42 }, + categories: [{ label: 'Retail', value: 'retail' }], + status: 'critical', + }, +] + +describe('React city integration', () => { + it('server renders the complete map and composable chart without WebGL', () => { + const map = renderToString() + expect(map).toContain('Store health') + expect(map).toContain('data-city-surface') + expect(map).toContain('data-city-fallback') + expect(map).toContain('ts-chart__rect') + expect(map).toContain('Camera controls') + expect(map).not.toContain(', + ) + expect(chart).toContain('data-city-surface') + expect(chart).not.toContain(' { + const result = await build({ + stdin: { + contents: "export { Chart } from '@tanstack/react-charts'", + resolveDir: fileURLToPath(new URL('.', import.meta.url)), + loader: 'ts', + }, + bundle: true, + format: 'esm', + write: false, + metafile: true, + external: ['react', 'react-dom'], + }) + expect( + Object.keys(result.metafile!.inputs).some( + (path) => path.includes('/city') || path.includes('/three/'), + ), + ).toBe(false) + }) +}) diff --git a/packages/react-charts/src/city.ts b/packages/react-charts/src/city.ts new file mode 100644 index 00000000..06336f48 --- /dev/null +++ b/packages/react-charts/src/city.ts @@ -0,0 +1,34 @@ +'use client' + +import * as runtime from './city/index.js' +import { CityMap as CityMapImplementation } from './city/map.jsx' +import { CityChart as CityChartImplementation } from './city/react.jsx' +import { EnvironmentControls as EnvironmentControlsImplementation } from './city/environment-controls.jsx' +import type * as Contract from './city/types' + +export const defineCityChart: typeof Contract.defineCityChart = + runtime.defineCityChart +export const cityMark: typeof Contract.cityMark = runtime.cityMark +export const cityFocus: typeof Contract.cityFocus = runtime.cityFocus +export const createCityRenderer: typeof Contract.createCityRenderer = + runtime.createCityRenderer +export const CityMap: typeof Contract.CityMap = CityMapImplementation +export const CityChart: typeof Contract.CityChart = CityChartImplementation +export const EnvironmentControls: typeof Contract.EnvironmentControls = + EnvironmentControlsImplementation +export type { + CityDatum, + CityLayout, + CityDefinitionOptions, + CityEngine, + CityRendererOptions, + CityView, + CityWeather, + CityLighting, + CityStats, + CityChartHandle, + CityMapProps, + CityChartProps, + CityChartState, + EnvironmentControlsProps, +} from './city/types' diff --git a/packages/react-charts/src/city/camera.test.mjs b/packages/react-charts/src/city/camera.test.mjs new file mode 100644 index 00000000..0dc57881 --- /dev/null +++ b/packages/react-charts/src/city/camera.test.mjs @@ -0,0 +1,133 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import * as THREE from 'three' +import { OrbitControls } from 'three/addons/controls/OrbitControls.js' +import { City } from './engine/city.js' + +test('isometric view keeps a fixed tilt when rotating, recentering and returning from the top view', () => { + const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 800) + camera.position.set(160, 160, 160) + const controls = new OrbitControls(camera, null), + city = { camera, controls, layout: { center: [14, -8] } } + const angle = Math.acos(1 / Math.sqrt(3)) + for (const previousMode of ['iso', 'top']) { + City.prototype.view.call(city, previousMode, true) + if (previousMode === 'top') assert.ok(controls.getPolarAngle() < 0.00001) + City.prototype.view.call(city, 'iso', true) + assert.deepEqual(controls.target.toArray(), [14, 0, -8]) + let azimuth + for (const offset of [ + [150, 5, 10], + [-150, 300, 10], + [10, 500, -150], + ]) { + camera.position.copy(controls.target).add(new THREE.Vector3(...offset)) + controls.update() + assert.ok(Math.abs(controls.getPolarAngle() - angle) < 1e-10) + if (azimuth !== undefined) + assert.notEqual(controls.getAzimuthalAngle(), azimuth) + azimuth = controls.getAzimuthalAngle() + } + camera.zoom = 2 + camera.updateProjectionMatrix() + controls.target.x += 5 + controls.update() + assert.equal(camera.zoom, 2) + assert.ok(Math.abs(controls.getPolarAngle() - angle) < 1e-10) + } +}) + +test('a frame older than a reduced-motion transition cannot overshoot the camera target', () => { + const previousDocument = globalThis.document + globalThis.document = { hidden: false } + try { + const camera = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 800) + camera.position.set(160, 160, 160) + const controls = new OrbitControls(camera, null) + const city = { + camera, + controls, + layout: { center: [14, -8] }, + running: true, + reduceMotion: true, + elapsed: 0, + callbacks: {}, + renderer: { render() {} }, + invalidate() {}, + updateEffects() {}, + } + City.prototype.view.call(city, 'iso', true) + City.prototype.view.call(city, 'top') + const start = city.fly.start + City.prototype.animate.call(city, start - 1) + assert.ok(city.fly, 'an earlier RAF must not complete the transition') + City.prototype.animate.call(city, start + 2) + assert.equal(city.fly, null) + assert.ok(Math.abs(controls.getAzimuthalAngle()) < 1e-9) + assert.ok(controls.getPolarAngle() < 0.00001) + assert.deepEqual(controls.target.toArray(), [14, 0, -8]) + } finally { + if (previousDocument === undefined) delete globalThis.document + else globalThis.document = previousDocument + } +}) + +test('auto-rotation is opt-in, slow, frame-rate independent and pauses when controls are locked', () => { + const previousDocument = globalThis.document + globalThis.document = { hidden: false } + try { + const results = [] + for (const fps of [5, 30, 60, 120]) { + const camera = new THREE.OrthographicCamera( + -100, + 100, + 100, + -100, + 0.1, + 800, + ) + camera.position.set(160, 160, 160) + const controls = new OrbitControls(camera, null) + controls.autoRotateSpeed = -0.3 + controls.update() + const city = { + camera, + controls, + running: true, + life: false, + effects: false, + reduceMotion: true, + elapsed: 0, + callbacks: {}, + renderer: { render() {} }, + invalidate() {}, + updateEffects() {}, + } + const angle = controls.getAzimuthalAngle() + City.prototype.animate.call(city, 1000) + City.prototype.animate.call(city, 2000) + assert.equal(controls.getAzimuthalAngle(), angle) + City.prototype.setAutoRotate.call(city, true) + City.prototype.animate.call(city, 3000) + for (let frame = 1; frame <= fps * 10; frame++) { + City.prototype.animate.call(city, 3000 + (frame * 1000) / fps) + } + const delta = controls.getAzimuthalAngle() - angle + assert.ok(Math.abs(delta - Math.PI / 10) < 1e-9) + results.push(delta) + controls.enabled = false + const paused = controls.getAzimuthalAngle() + City.prototype.animate.call(city, 13100) + assert.ok(Math.abs(controls.getAzimuthalAngle() - paused) < 1e-9) + controls.enabled = true + City.prototype.setAutoRotate.call(city, false) + City.prototype.animate.call(city, 14000) + City.prototype.animate.call(city, 14050) + assert.ok(Math.abs(controls.getAzimuthalAngle() - paused) < 1e-9) + } + assert.ok(Math.max(...results) - Math.min(...results) < 1e-9) + } finally { + if (previousDocument === undefined) delete globalThis.document + else globalThis.document = previousDocument + } +}) diff --git a/packages/react-charts/src/city/city-chart.test.mjs b/packages/react-charts/src/city/city-chart.test.mjs new file mode 100644 index 00000000..f0be1e37 --- /dev/null +++ b/packages/react-charts/src/city/city-chart.test.mjs @@ -0,0 +1,158 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { defineChart } from '@tanstack/charts' +import { createChartRuntime } from '@tanstack/charts/runtime' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { + cityMark, + defineCityChart, + createCityRenderer, + cityFocus, +} from './index.js' +import { bindCityPoints } from './interaction.js' + +import { readCityLayout } from './layout.js' +import { layoutCity } from './engine/model.js' + +const data = Object.freeze([ + Object.freeze({ + id: 'one', + name: 'Empresa saudável', + users: 42, + mask: 1, + status: 'healthy', + }), + Object.freeze({ + id: 'two', + name: 'Empresa em risco', + users: 2400, + mask: 3, + status: 'critical', + }), +]) +const sceneFor = (definition, size = { width: 800, height: 450 }) => { + const runtime = createChartRuntime() + return runtime.render(definition, size) +} + +test('cityMark composes a TanStack definition and preserves datum, indices, keys and colors', () => { + const definition = defineChart({ + marks: [cityMark(data)], + scales: { x: { scale: scaleLinear }, y: { scale: scaleLinear } }, + color: { domain: [1, 3], range: ['#123456', '#abcdef'] }, + guides: false, + }) + const scene = sceneFor(definition) + assert.equal(scene.points.length, data.length) + scene.points.forEach((point, i) => { + assert.equal(point.datum, data[i]) + assert.equal(point.datumIndex, i) + assert.equal(point.key, `companies:${data[i].id}`) + assert.ok(Number.isFinite(point.x) && Number.isFinite(point.y)) + }) + assert.deepEqual( + scene.points.map((p) => p.color), + ['#123456', '#abcdef'], + ) + assert.equal(data[0].x, undefined) + assert.equal(data[0].height, undefined) +}) + +test('the definition is deterministic, responsive and accepts an empty dataset', () => { + const definition = defineCityChart(data) + const first = sceneFor(definition) + const wide = sceneFor(definition, { width: 1200, height: 450 }) + assert.deepEqual( + first.points.map((p) => p.key), + wide.points.map((p) => p.key), + ) + assert.deepEqual( + first.points.map((p) => [p.xValue, p.yValue]), + wide.points.map((p) => [p.xValue, p.yValue]), + ) + assert.deepEqual( + first.points.map((p) => [p.x, p.y]), + sceneFor(definition).points.map((p) => [p.x, p.y]), + ) + assert.equal(sceneFor(defineCityChart([])).points.length, 0) +}) + +test('focus uses each mount surface without mixing two cities', () => { + const one = sceneFor(defineCityChart(data)).points + const two = sceneFor(defineCityChart(data)).points + bindCityPoints(one, () => [one[0]]) + bindCityPoints(two, () => [two[1]]) + assert.equal(cityFocus.resolve(one, { x: 0, y: 0 })[0], one[0]) + assert.equal(cityFocus.resolve(two, { x: 0, y: 0 })[0], two[1]) + assert.deepEqual(cityFocus.resolve([], { x: 0, y: 0 }), []) +}) + +test('the renderer generates an SSR shell without WebGL or a DOM and escapes the accessible name', () => { + const renderer = createCityRenderer() + const markup = renderer.prerender(sceneFor(defineCityChart(data)), { + ariaLabel: 'Saúde "A" & B', + }) + assert.match(markup, /data-city-surface/) + assert.match(markup, /Saúde <empresas> "A" & B/) + assert.doesNotMatch(markup, / { + for (const invalid of [ + null, + [{}], + [data[0], data[0]], + [{ ...data[0], users: NaN }], + [{ ...data[0], mask: 0 }], + [{ ...data[0], status: 'unknown' }], + ]) { + assert.throws(() => cityMark(invalid), /cityMark:/) + } +}) + +test('the layout follows the TanStack scene, including empty scenes, without recalculating positions', () => { + for (const rows of [data, []]) { + const layout = layoutCity(rows) + if (rows.length) layout.companies[0].x += 0.4 + const scene = sceneFor(defineCityChart(rows, { layout })) + assert.equal(readCityLayout(scene.nodes), layout) + if (rows.length) assert.equal(scene.points[0].xValue, layout.companies[0].x) + } +}) + +test('the mark accepts the generic contract without a mask and retains the original datum', () => { + const rows = [ + { + id: 'generic', + name: 'Aurora', + metric: { label: 'Receita', value: 2400.25 }, + categories: [{ label: 'Premium', value: 'premium' }], + status: 'warning', + }, + ] + const scene = sceneFor(defineCityChart(rows)) + const layout = readCityLayout(scene.nodes) + assert.equal(scene.points[0].datum, rows[0]) + assert.equal(scene.points[0].groupLabel, 'Premium') + assert.equal(layout.companies[0].mask, 1) + assert.equal(layout.companies[0].metric.value, 2400.25) + assert.equal(rows[0].mask, undefined) +}) + +test('the mark retains numeric IDs in the generic contract and validates normalized categories', () => { + const row = { + id: 101, + name: 'Aurora', + metric: { label: 'Receita', value: 42 }, + categories: [{ label: 'Premium', value: 'premium' }], + status: 'healthy', + } + const scene = sceneFor(defineCityChart([row])) + assert.equal(readCityLayout(scene.nodes).companies[0].id, 101) + assert.equal(scene.points[0].datum.id, 101) + assert.throws(() => cityMark([{ ...row, id: undefined }]), /cityMark: id/) + assert.throws( + () => cityMark([{ ...row, mask: 1, categories: [] }]), + /cityMark:/, + ) +}) diff --git a/packages/react-charts/src/city/city-i18n.test.mjs b/packages/react-charts/src/city/city-i18n.test.mjs new file mode 100644 index 00000000..ff966b66 --- /dev/null +++ b/packages/react-charts/src/city/city-i18n.test.mjs @@ -0,0 +1,55 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { + mapMessages, + resolveMapLocale, + getMapI18n, + getBrowserMapLocale, +} from './i18n.js' + +test('the map accepts both locales and uses English for unsupported languages', () => { + assert.equal(resolveMapLocale('pt-BR'), 'pt-BR') + assert.equal(resolveMapLocale('PT-br'), 'pt-BR') + assert.equal(resolveMapLocale('en-US'), 'en-US') + for (const language of [ + 'pt', + 'pt-PT', + 'en-GB', + 'es-ES', + 'fr-FR', + '', + null, + undefined, + ]) { + assert.equal(resolveMapLocale(language), 'en-US') + } + assert.equal( + getBrowserMapLocale(), + 'en-US', + 'SSR does not depend on the host locale', + ) +}) + +test('complete catalogs preserve parameters and format numbers in the locale', () => { + assert.deepEqual( + Object.keys(mapMessages['pt-BR']).sort(), + Object.keys(mapMessages['en-US']).sort(), + ) + for (const [key, value] of Object.entries(mapMessages['pt-BR'])) { + assert.ok(value.trim()) + const english = mapMessages['en-US'][key] + assert.ok(english.trim()) + assert.deepEqual( + value.match(/\{\w+\}/g)?.sort(), + english.match(/\{\w+\}/g)?.sort(), + key, + ) + } + const pt = getMapI18n('pt-BR') + const en = getMapI18n('en-US') + assert.equal(pt.format(2400.5), '2.400,5') + assert.equal(en.format(2400.5), '2,400.5') + assert.equal(pt.t('loadingCity', { page: 2 }), 'Carregando cidade 2…') + assert.equal(en.t('loadingCity', { page: 2 }), 'Loading city 2…') + assert.equal(getMapI18n('es-ES').t('title'), 'Business district') +}) diff --git a/packages/react-charts/src/city/city-lighting.test.mjs b/packages/react-charts/src/city/city-lighting.test.mjs new file mode 100644 index 00000000..7f8dc8f2 --- /dev/null +++ b/packages/react-charts/src/city/city-lighting.test.mjs @@ -0,0 +1,154 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import * as THREE from 'three' +import { + buildLighting, + advanceLighting, + LIGHTING_TRANSITION_MS, + lightingPeriod, + updateLighting, +} from './three/lighting.js' +import { buildBuildings } from './three/buildings.js' +import { updateEffects } from './three/effects.js' +import { disposeSceneGroups } from './engine/scene-resources.js' + +const localTime = (hour, minute = 0) => new Date(2026, 9, 18, hour, minute) +const settleLighting = (city, date) => { + updateLighting(city, date, 0) + advanceLighting(city, LIGHTING_TRANSITION_MS) +} + +test('the three levels follow local time, including period boundaries', () => { + for (const [hour, minute, expected] of [ + [0, 0, 'night'], + [5, 59, 'night'], + [6, 0, 'twilight'], + [7, 59, 'twilight'], + [8, 0, 'day'], + [16, 59, 'day'], + [17, 0, 'twilight'], + [18, 59, 'twilight'], + [19, 0, 'night'], + [23, 59, 'night'], + ]) { + assert.equal(lightingPeriod(localTime(hour, minute)), expected) + assert.equal(lightingPeriod(localTime(hour, minute), false), 'day') + } +}) + +test('windows follow lighting and rebuilds while preserving independent risk effects', () => { + const city = { + scene: new THREE.Scene(), + buildings: new THREE.Group(), + life: true, + effects: false, + camera: new THREE.PerspectiveCamera(), + palettes: new Map([[1, new THREE.Color('#4b91ef')]]), + plumes: { update() {} }, + warning: new THREE.InstancedMesh( + new THREE.PlaneGeometry(), + new THREE.MeshBasicMaterial(), + 3, + ), + active: new Set([1, 2, 3]), + } + city.scene.background = new THREE.Color() + city.scene.add(city.buildings, city.warning) + buildLighting(city) + const companies = ['healthy', 'warning', 'critical'].map((status, i) => ({ + id: i + 1, + x: i * 3, + z: 0, + users: 1000, + mask: 1, + status, + })) + settleLighting(city, localTime(12)) + buildBuildings(city, companies) + assert.equal(city.windows.material.emissiveIntensity, 0) + assert.equal(city.scene.background.getHexString(), 'e9ede4') + assert.equal(city.lights.hemisphere.intensity, 2.5) + assert.equal(city.lights.sun.intensity, 3.1) + assert.equal(city.lights.fill.intensity, 0.75) + const body = city.body + for (const hour of [18, 22]) { + settleLighting(city, localTime(hour)) + updateEffects(city, 0) + assert.equal(city.body, body) + assert.ok(city.windows.material.emissiveIntensity > 0) + const color = new THREE.Color() + for (let i = 0; i < city.windowGlow.count; i++) { + city.windowGlow.getColorAt(i, color) + assert.equal(color.getHexString(), 'ffe0a3') + } + assert.equal(city.warning.visible, false) + } + city.effects = true + updateEffects(city, 0) + assert.equal(city.warning.visible, true) + const color = new THREE.Color() + const criticalIndex = city.windowGlow.userData.items.findIndex( + (item) => item.source.critical, + ) + city.windowGlow.getColorAt(criticalIndex, color) + assert.ok(color.r > color.g && color.g > color.b) + + disposeSceneGroups([city.buildings]) + buildBuildings(city, companies) + assert.ok(city.windows.material.emissiveIntensity > 0) + city.life = false + settleLighting(city, localTime(22)) + updateEffects(city, 0) + assert.equal(city.lightingPeriod, 'day') + assert.equal(city.windows.material.emissiveIntensity, 0) + assert.equal(city.scene.background.getHexString(), 'e9ede4') + assert.equal(city.warning.visible, true) + assert.equal(updateLighting(city, localTime(22)), false) + disposeSceneGroups([city.scene]) +}) + +test('lighting and windows interpolate without jumps when the target changes during a transition', () => { + const city = { + scene: new THREE.Scene(), + life: true, + lightingMode: 'day', + windows: new THREE.Mesh( + new THREE.PlaneGeometry(), + new THREE.MeshStandardMaterial(), + ), + } + city.scene.background = new THREE.Color() + city.scene.add(city.windows) + buildLighting(city) + const day = city.scene.background.clone() + city.lightingMode = 'night' + updateLighting(city, localTime(12), 0) + assert.ok(city.scene.background.equals(day)) + assert.equal(city.windows.material.emissiveIntensity, 0) + advanceLighting(city, LIGHTING_TRANSITION_MS / 2) + assert.equal(city.windowLight, 0.5) + assert.equal(city.windows.material.emissiveIntensity, 0.575) + assert.ok(city.lights.hemisphere.intensity > 0.65) + assert.ok(city.lights.hemisphere.intensity < 2.5) + assert.ok(!city.scene.background.equals(day)) + const halfway = city.scene.background.clone() + city.lightingMode = 'twilight' + updateLighting(city, localTime(12), LIGHTING_TRANSITION_MS / 2) + assert.ok(city.scene.background.equals(halfway)) + assert.equal(city.windowLight, 0.5) + advanceLighting(city, LIGHTING_TRANSITION_MS) + assert.equal(city.windowLight, 0.75) + advanceLighting(city, LIGHTING_TRANSITION_MS * 1.5) + assert.equal(city.scene.background.getHexString(), 'b99086') + assert.equal(city.windowLight, 1) + assert.equal(city.lightingTransition, null) + assert.equal(advanceLighting(city, LIGHTING_TRANSITION_MS * 2), false) + city.life = false + updateLighting(city, localTime(12), LIGHTING_TRANSITION_MS * 2) + advanceLighting(city, LIGHTING_TRANSITION_MS * 2.5) + assert.equal(city.windowLight, 0.5) + advanceLighting(city, LIGHTING_TRANSITION_MS * 3) + assert.ok(city.scene.background.equals(day)) + assert.equal(city.windows.material.emissiveIntensity, 0) + disposeSceneGroups([city.scene]) +}) diff --git a/packages/react-charts/src/city/city-slider.test.mjs b/packages/react-charts/src/city/city-slider.test.mjs new file mode 100644 index 00000000..d83f2071 --- /dev/null +++ b/packages/react-charts/src/city/city-slider.test.mjs @@ -0,0 +1,138 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { CitySlider } from './engine/city-slider.js' + +function setup(t) { + const originalWindow = globalThis.window, + originalDocument = globalThis.document + const animations = [] + class Element { + children = [] + classList = { add() {}, remove() {} } + append(...children) { + this.children.push(...children) + for (const child of children) child.parent = this + } + replaceChildren(...children) { + this.children = [] + this.append(...children) + } + remove() { + if (this.parent) + this.parent.children = this.parent.children.filter((c) => c !== this) + } + setAttribute() {} + removeAttribute() {} + cloneNode() { + return new Element() + } + getContext() { + return { drawImage() {} } + } + animate(frames) { + let resolve, reject + const animation = { + frames, + finished: new Promise((a, b) => { + resolve = a + reject = b + }), + finish() { + resolve() + }, + cancel() { + reject(new Error('cancelled')) + }, + } + animations.push(animation) + return animation + } + } + globalThis.window = { addEventListener() {} } + globalThis.document = { createElement: () => new Element() } + t.onTestFinished(() => { + globalThis.window = originalWindow + globalThis.document = originalDocument + }) + const city = { + reduceMotion: false, + controls: { enabled: true }, + renderer: { domElement: new Element(), render() {} }, + } + const panel = new Element(), + labels = new Element(), + slider = new CitySlider(city, panel, labels) + return { city, panel, slider, animations } +} + +test('next and previous move scenes in opposite directions and unlock the map on completion', async (t) => { + const { city, panel, slider, animations } = setup(t) + for (const direction of [1, -1]) { + animations.length = 0 + slider.capture() + const snapshot = slider.state.canvas + assert.equal(city.controls.enabled, false) + const done = slider.play(direction) + assert.equal( + animations[0].frames[1].transform, + `translateX(${-direction * 100}%)`, + ) + assert.equal( + animations[1].frames[0].transform, + `translateX(${direction * 100}%)`, + ) + assert.deepEqual(animations[1].frames, animations[2].frames) + for (const animation of animations) animation.finish() + await done + assert.equal(slider.active, false) + assert.equal(city.controls.enabled, true) + assert.equal(panel.children.length, 0) + assert.equal(snapshot.width, 0) + assert.equal(snapshot.height, 0) + } +}) + +test('canceling during filtering removes the old scene without ending a subsequent transition', async (t) => { + const { city, panel, slider, animations } = setup(t) + slider.capture() + const first = slider.play(1) + slider.cancel() + assert.equal(panel.children.length, 0) + assert.equal(city.controls.enabled, true) + slider.capture() + const next = slider.play(-1) + await first + assert.equal(slider.active, true) + assert.equal(panel.children.length, 1) + for (const animation of animations.slice(3)) animation.finish() + await next + assert.equal(slider.active, false) + assert.equal(panel.children.length, 0) +}) + +test('reduced motion switches the city without a snapshot or locking controls', async (t) => { + const { city, panel, slider, animations } = setup(t) + city.reduceMotion = true + slider.capture() + await slider.play(1) + assert.equal(animations.length, 0) + assert.equal(panel.children.length, 0) + assert.equal(city.controls.enabled, true) +}) + +test('snapshots preserve label CSS positions without changing the incoming city', (t) => { + const { panel, slider } = setup(t) + const rule = { + cssText: + '[data-atlas-city-instance="one"] .atlas-city-label[data-region="1"] { left: 10px; top: 20px; }', + } + panel.querySelector = () => ({ sheet: { cssRules: [rule] } }) + slider.capture() + const style = slider.state.overlay.children.at(-1) + assert.match(style.textContent, /\.city-slide-labels \.atlas-city-label/) + assert.match(style.textContent, /left: 10px/) + rule.cssText = rule.cssText.replace('10px', '100px') + assert.match(style.textContent, /left: 10px/) + slider.cancel() + assert.equal(panel.children.length, 0) +}) diff --git a/packages/react-charts/src/city/company-monitor.jsx b/packages/react-charts/src/city/company-monitor.jsx new file mode 100644 index 00000000..ec16c5fc --- /dev/null +++ b/packages/react-charts/src/city/company-monitor.jsx @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState } from 'react' +import { useMapI18n } from './use-map-i18n.js' +import { + recentCriticalCompanies, + timestampValue, +} from './engine/lib/company-dates.js' + +export function CompanyDate({ value }) { + const { locale, t } = useMapI18n() + if (!Number.isFinite(timestampValue(value))) + return {t('dateUnknown')} + return ( + + ) +} + +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/critical-blink.test.mjs b/packages/react-charts/src/city/critical-blink.test.mjs new file mode 100644 index 00000000..a0092771 --- /dev/null +++ b/packages/react-charts/src/city/critical-blink.test.mjs @@ -0,0 +1,36 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { createCriticalBlink } from './engine/critical-blink.js' + +test('critical companies have distinct rhythms and irregular intervals without gradual transitions', () => { + const patterns = [] + for (let company = 0; company < 30; company++) { + const blink = createCriticalBlink(`empresa-${company}`), + changes = [] + let previous = blink(0), + pattern = String(previous) + for (let frame = 1; frame <= 6000; frame++) { + const t = frame / 100, + on = blink(t) + assert.equal(typeof on, 'boolean') + if (on !== previous) { + changes.push(t) + previous = on + } + if (frame % 10 === 0) pattern += Number(on) + } + assert.ok(changes.length > 30) + const intervals = changes.slice(1).map((t, i) => t - changes[i]) + assert.ok(intervals.every((d) => d >= 0.439 && d <= 1.111)) + assert.ok(Math.max(...intervals) - Math.min(...intervals) > 0.2) + patterns.push(pattern) + } + assert.equal(new Set(patterns).size, patterns.length) +}) + +test('facade and roof keep the same rhythm after rebuilds and frame jumps', () => { + const body = createCriticalBlink('empresa-crítica'), + roof = createCriticalBlink('empresa-crítica') + for (const t of [0, 0.1, 0.8, 12, 85, 10000, 3]) + assert.equal(body(t), roof(t)) +}) diff --git a/packages/react-charts/src/city/data.js b/packages/react-charts/src/city/data.js new file mode 100644 index 00000000..daeb4c5d --- /dev/null +++ b/packages/react-charts/src/city/data.js @@ -0,0 +1,61 @@ +import { REGIONS, STATUS, metricFor, placeCompanies } from './engine/model.js' +import { normalizeCompanyDates } from './engine/lib/company-dates.js' + +/** + * @typedef {object} CityDatum + * @property {string|number} id Stable, unique company key. + * @property {string} name Company display name. + * @property {{label: string, value: number}} metric Nonnegative metric; encodes building height. + * @property {{label: string, value: string}[]} [categories] Generic district memberships. + * @property {1|2|3|4|5|6|7} [mask] Normalized category intersection; encodes district/color. + * @property {"healthy"|"warning"|"critical"} status Health classification. + * @property {string} [updatedAt] Last record update, ISO 8601 with timezone. + * @property {string} [statusChangedAt] Start of the current health status. + */ + +/** Validate at the chart boundary, before layout or GPU allocation. */ +export function validateCityData(data) { + if (!Array.isArray(data)) + throw new TypeError('cityMark: data deve ser uma lista.') + const ids = new Set() + for (const [index, datum] of data.entries()) { + if ( + !datum || + !['string', 'number'].includes(typeof datum.id) || + !String(datum.id).trim() || + ids.has(String(datum.id)) + ) + throw new TypeError(`cityMark: id ausente ou repetido na linha ${index}.`) + ids.add(String(datum.id)) + } + let rows = data + if (data.some((datum) => datum.categories !== undefined)) { + try { + const normalized = placeCompanies(data) + if (data.some((datum) => datum.mask === undefined)) + rows = normalized.map((datum, index) => ({ + ...datum, + id: data[index].id, + })) + } catch (error) { + throw new TypeError(`cityMark: ${error.message}`) + } + } + for (const [index, datum] of rows.entries()) { + normalizeCompanyDates(datum, index) + if ( + typeof datum.name !== 'string' || + !datum.name.trim() || + typeof metricFor(datum).label !== 'string' || + !metricFor(datum).label.trim() || + !Number.isFinite(metricFor(datum).value) || + metricFor(datum).value < 0 || + !REGIONS.some((region) => region.mask === datum.mask) || + !Object.hasOwn(STATUS, datum.status) + ) + throw new TypeError( + `cityMark: nome, métrica, categorias ou saúde inválidos na linha ${index}.`, + ) + } + return rows +} diff --git a/packages/react-charts/src/city/definition.js b/packages/react-charts/src/city/definition.js new file mode 100644 index 00000000..0c3192c6 --- /dev/null +++ b/packages/react-charts/src/city/definition.js @@ -0,0 +1,24 @@ +import { defineChart } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { cityMark } from './mark.js' +import { cityFocus } from './interaction.js' +import { REGIONS } from './engine/model.js' + +/** A convenience preset; accepts the usual TanStack definition options. */ +export function defineCityChart(data, { layout, ...options } = {}) { + return defineChart({ + marks: [cityMark(data, { layout })], + scales: { + x: { scale: scaleLinear, axis: false }, + y: { scale: scaleLinear, axis: false }, + }, + color: { + domain: REGIONS.map((r) => r.mask), + range: REGIONS.map((r) => r.color), + }, + guides: false, + margin: 0, + focus: cityFocus, + ...options, + }) +} diff --git a/packages/react-charts/src/city/engine/city-slider.js b/packages/react-charts/src/city/engine/city-slider.js new file mode 100644 index 00000000..dd16da28 --- /dev/null +++ b/packages/react-charts/src/city/engine/city-slider.js @@ -0,0 +1,100 @@ +// Keep only one live WebGL city; the departing city is a temporary snapshot. +export class CitySlider { + constructor(city, panel, labels) { + this.city = city + this.panel = panel + this.labels = labels + this.state = null + this.onResize = () => this.cancel() + window.addEventListener('resize', this.onResize) + } + get active() { + return this.state !== null + } + dispose() { + this.cancel() + window.removeEventListener('resize', this.onResize) + } + capture() { + this.cancel() + if (this.city.reduceMotion || !this.panel.animate) return + const renderer = this.city.renderer, + source = renderer.domElement + const canvas = document.createElement('canvas') + canvas.width = source.clientWidth || source.width + canvas.height = source.clientHeight || source.height + const context = canvas.getContext('2d') + if (!context) return + renderer.render(this.city.scene, this.city.camera) + context.drawImage(source, 0, 0, canvas.width, canvas.height) + const overlay = document.createElement('div') + overlay.className = 'city-slide-snapshot' + overlay.setAttribute('aria-hidden', 'true') + const labels = this.labels.cloneNode(true) + labels.removeAttribute('id') + labels.className = 'city-slide-labels' + overlay.append(canvas, labels) + // Freeze the old labels' CSS positions while the live stylesheet moves + // to the next layout. The more specific rules leave the live labels alone. + const projectionSheet = this.panel.querySelector?.( + 'style[data-atlas-city-projections]', + )?.sheet + if (projectionSheet) { + const style = document.createElement('style') + style.textContent = Array.from(projectionSheet.cssRules, (rule) => + rule.cssText.replace( + ' .atlas-city-label[', + ' .city-slide-labels .atlas-city-label[', + ), + ).join('\n') + overlay.append(style) + } + this.panel.append(overlay) + this.state = { overlay, canvas, animations: [] } + this.city.controls.enabled = false + this.panel.classList.add('city-sliding') + } + async play(direction) { + const state = this.state + if (!state) return + const options = { + duration: 650, + easing: 'cubic-bezier(.22,.68,.2,1)', + fill: 'both', + } + const incoming = [ + { transform: `translateX(${direction * 100}%)` }, + { transform: 'translateX(0)' }, + ] + this.city.renderer.render(this.city.scene, this.city.camera) + state.animations = [ + state.overlay.animate( + [ + { transform: 'translateX(0)' }, + { transform: `translateX(${-direction * 100}%)` }, + ], + options, + ), + this.city.renderer.domElement.animate(incoming, options), + this.labels.animate(incoming, options), + ] + await Promise.allSettled( + state.animations.map((animation) => animation.finished), + ) + if (this.state === state) this.cancel() + } + cancel() { + if (!this.state) return + const state = this.state + this.state = null + for (const animation of state.animations) animation.cancel() + state.animations.length = 0 + state.overlay.remove() + state.overlay.replaceChildren() + state.canvas.width = 0 + state.canvas.height = 0 + this.panel.classList.remove('city-sliding') + this.city.controls.enabled = true + this.city.invalidate?.() + } +} diff --git a/packages/react-charts/src/city/engine/city.js b/packages/react-charts/src/city/engine/city.js new file mode 100644 index 00000000..2a3d8c22 --- /dev/null +++ b/packages/react-charts/src/city/engine/city.js @@ -0,0 +1,473 @@ +import * as THREE from 'three' +import { OrbitControls } from 'three/addons/controls/OrbitControls.js' +import { disposeSceneGroups } from './scene-resources.js' +import { SelectionHighlight } from './selection-highlight.js' +import { REGIONS, metricFor } from './model.js' +import { buildTerrain } from '../three/terrain.js' +import { buildBuildings } from '../three/buildings.js' +import { buildTraffic, updateTraffic } from '../three/traffic.js' +import { buildEffects, updateEffects } from '../three/effects.js' +import { CityWeather } from '../three/weather.js' +import { + advanceLighting, + buildLighting, + updateLighting, +} from '../three/lighting.js' + +const ISO_POLAR = Math.acos(1 / Math.sqrt(3)) +export class City { + constructor(container, callbacks = {}) { + this.container = container + this.callbacks = callbacks + this.palettes = new Map( + REGIONS.map((r) => [r.mask, new THREE.Color(r.color)]), + ) + this.active = new Set() + this.life = true + this.lightingMode = 'auto' + this.effects = true + this.autoRotate = false + this.weather = 'none' + this.running = true + this.elapsed = 0 + this.previous = 0 + this.cameraRevision = 0 + this.animationId = null + this.animate = this.animate.bind(this) + this.reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches + this.scene = new THREE.Scene() + this.scene.background = new THREE.Color('#e9ede4') + this.camera = new THREE.OrthographicCamera(-110, 110, 90, -90, 0.1, 800) + this.camera.position.set(160, 160, 160) + this.renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + powerPreference: 'high-performance', + }) + this.renderer.setPixelRatio(Math.min(devicePixelRatio, 1.7)) + this.renderer.outputColorSpace = THREE.SRGBColorSpace + this.renderer.toneMapping = THREE.ACESFilmicToneMapping + this.renderer.toneMappingExposure = 1.22 + container.appendChild(this.renderer.domElement) + this.controls = new OrbitControls(this.camera, this.renderer.domElement) + this.controls.enableDamping = true + this.controls.dampingFactor = 0.075 + this.controls.minPolarAngle = ISO_POLAR + this.controls.maxPolarAngle = ISO_POLAR + this.controls.minZoom = 0.45 + this.controls.maxZoom = 12 + this.controls.zoomSpeed = 0.85 + this.controls.rotateSpeed = 0.5 + this.controls.autoRotateSpeed = -0.3 // Clockwise, one revolution in about 200 seconds. + this.controls.screenSpacePanning = true + this.controls.target.set(0, 0, 5) + this.controls.update() + buildLighting(this) + this.terrain = new THREE.Group() + this.scene.add(this.terrain) + this.buildings = new THREE.Group() + this.scene.add(this.buildings) + this.raycaster = new THREE.Raycaster() + this.pointer = new THREE.Vector2() + this.selection = new SelectionHighlight() + this.scene.add(this.selection) + this.onCameraChange = () => { + this.cameraRevision++ + this.invalidate() + } + this.controls.addEventListener('change', this.onCameraChange) + this.observer = new ResizeObserver(() => this.resize()) + this.observer.observe(container) + this.resize() + this.onVisibility = () => { + this.previous = 0 + this.refreshLighting() + this.invalidate() + } + document.addEventListener('visibilitychange', this.onVisibility) + this.syncLightingClock() + this.invalidate() + } + resize() { + const w = this.container.clientWidth, + h = this.container.clientHeight + if (!w || !h) return + const sizeKey = `${w}:${h}:${this.homeHalf}` + if (this.sizeKey === sizeKey) return + this.sizeKey = sizeKey + const aspect = w / h + const half = + aspect < 1 ? (this.homeHalf ?? 32) / aspect : (this.homeHalf ?? 32) + this.camera.left = -half * aspect + this.camera.right = half * aspect + this.camera.top = half + this.camera.bottom = -half + this.camera.updateProjectionMatrix() + this.renderer.setSize(w, h) + this.cameraRevision++ + this.invalidate() + } + buildTerrain() { + buildTerrain(this) + } + setCompanies(companies, layout, { colors } = {}) { + if (colors) + for (const [mask, color] of colors) + this.palettes.set(mask, new THREE.Color(color)) + this.layout = layout + this.homeHalf = Math.max( + 18, + Math.max( + layout.roadBounds.x2 - layout.roadBounds.x1, + layout.roadBounds.z2 - layout.roadBounds.z1, + ) * + 2.8 * + 0.7, + ) + this.resize() + disposeSceneGroups([this.terrain, this.traffic, this.buildings]) + if (this.traffic) this.scene.remove(this.traffic) + this.renderer.renderLists.dispose() + this.warningTexture = null + this.plumes = null + this.simulation = null + this.body = null + this.windows = null + this.windowGlow = null + this.warning = null + this.criticalSurfaces = [] + this.cars = null + this.carTops = null + this.walkers = null + this.heads = null + this.signalLamps = null + this.emitters = [] + this.warningSources = [] + this.lampItems = [] + this.companies = [] + this.buildTerrain() + this.buildTraffic() + this.companies = companies + this.active = new Set(companies.map((c) => c.id)) + this.companiesById = new Map(companies.map((c) => [c.id, c])) + buildBuildings(this, companies) + this.weatherParticles?.setLayout(layout) + this.buildEffects() + this.selectedId = null + this.selection.visible = false + this.invalidate() + } + // Topology owns terrain/traffic. Metrics and health own only buildings/effects. + // The caller supplies private rows, so Three.js never mutates ChartPoint.datum. + updateCompanies(layout, colors) { + const next = layout.companies + const topologyChanged = + !this.layout || + ['x1', 'x2', 'z1', 'z2'].some( + (k) => this.layout.roadBounds[k] !== layout.roadBounds[k], + ) || + next.length !== this.companies.length || + next.some((c, i) => + ['id', 'mask', 'x', 'z', 'col', 'row'].some( + (k) => c[k] !== this.companies[i][k], + ), + ) + if (topologyChanged) { + this.setCompanies(next, layout, { colors }) + return true + } + const buildingsChanged = + JSON.stringify(layout.metricDomain) !== + JSON.stringify(this.layout.metricDomain) || + next.some( + (c, i) => + metricFor(c).value !== metricFor(this.companies[i]).value || + c.status !== this.companies[i].status, + ) + for (const [mask, color] of colors) + this.palettes.set(mask, new THREE.Color(color)) + if (buildingsChanged) { + disposeSceneGroups([this.buildings]) + this.renderer.renderLists.dispose() + this.warningTexture = null + this.companies = next + this.layout = layout + buildBuildings(this, next) + this.buildEffects() + } else { + next.forEach((c, i) => Object.assign(this.companies[i], c)) + // Preserve per-building shading and the current critical blink phase. + for (const item of this.body.userData.items) { + const company = this.companiesById.get(item.company) + item.color + .copy(this.palettes.get(company.mask)) + .multiplyScalar(item.shade) + } + this.body.userData.items.forEach((item, i) => + this.body.setColorAt(i, item.color), + ) + if (this.body.instanceColor) this.body.instanceColor.needsUpdate = true + for (const surface of this.criticalSurfaces) + for (const item of surface.items) { + item.color.copy(this.body.userData.items[item.index].color) + item.lastRed = undefined + } + } + this.effectsKey = null + this.layout = { ...layout, companies: this.companies } + this.weatherParticles?.setLayout(this.layout) + this.companiesById = new Map(this.companies.map((c) => [c.id, c])) + this.select(this.companiesById.get(this.selectedId)) + this.invalidate() + return false + } + buildEffects() { + buildEffects(this) + } + updateEffects(t) { + updateEffects(this, t) + } + buildTraffic() { + buildTraffic(this) + } + updateTraffic(dt) { + updateTraffic(this, dt) + } + pick(e) { + if (!this.body) return null + const rect = this.renderer.domElement.getBoundingClientRect() + this.pointer.set( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + (-(e.clientY - rect.top) / rect.height) * 2 + 1, + ) + this.raycaster.setFromCamera(this.pointer, this.camera) + const hit = this.raycaster + .intersectObject(this.body, false) + .find( + (h) => + h.instanceId !== undefined && + this.active.has(this.companies[h.instanceId].id), + ) + return hit ? this.companies[hit.instanceId] : null + } + select(c) { + this.selectedId = c?.id ?? null + if (c) this.selection.select(c) + else this.selection.visible = false + this.invalidate() + } + focus(c, { maxZoom = false } = {}) { + this.select(c) + const direction = this.camera.position + .clone() + .sub(this.controls.target) + .normalize() + const target = new THREE.Vector3(c.x, c.height * 0.45, c.z) + this.fly = { + fromPosition: this.camera.position.clone(), + toPosition: target.clone().add(direction.multiplyScalar(180)), + fromTarget: this.controls.target.clone(), + toTarget: target, + fromZoom: this.camera.zoom, + toZoom: maxZoom + ? this.controls.maxZoom + : Math.min(5, Math.max(1.8, this.homeHalf / 15)), + start: performance.now(), + } + this.invalidate?.() + } + view(mode = 'iso', immediate = false) { + // Flush pending drag damping so it cannot offset the restored view. + const damping = this.controls.enableDamping + this.controls.enableDamping = false + this.controls.update() + this.controls.enableDamping = damping + const [x, z] = this.layout?.center ?? [0, 0] + const target = new THREE.Vector3(x, 0, z), + pos = + mode === 'top' + ? new THREE.Vector3(x, 230, z + 0.001) + : new THREE.Vector3(x + 160, 160, z + 160) + // Dragging changes azimuth only; the view buttons choose the fixed elevation. + this.controls.minPolarAngle = this.controls.maxPolarAngle = + mode === 'top' ? 1e-6 : ISO_POLAR + if (immediate) { + this.fly = null + this.camera.position.copy(pos) + this.camera.zoom = 1 + this.controls.target.copy(target) + this.controls.update() + this.camera.updateProjectionMatrix() + this.camera.updateMatrixWorld() + this.cameraRevision = (this.cameraRevision ?? 0) + 1 + this.invalidate?.() + return + } + this.fly = { + fromPosition: this.camera.position.clone(), + toPosition: pos, + fromTarget: this.controls.target.clone(), + toTarget: target, + fromZoom: this.camera.zoom, + toZoom: 1, + start: performance.now(), + } + this.invalidate?.() + } + zoom(factor) { + this.fly = null + this.camera.zoom = THREE.MathUtils.clamp( + this.camera.zoom * factor, + 0.45, + 12, + ) + this.camera.updateProjectionMatrix() + this.cameraRevision++ + this.invalidate() + } + project(x, y, z) { + return new THREE.Vector3(x, y, z).project(this.camera) + } + dispose() { + this.running = false + clearInterval(this.lightingTimer) + cancelAnimationFrame(this.animationId) + this.observer.disconnect() + this.controls.removeEventListener('change', this.onCameraChange) + this.controls.dispose() + document.removeEventListener('visibilitychange', this.onVisibility) + disposeSceneGroups([this.scene]) + this.renderer.renderLists.dispose() + this.renderer.dispose() + this.renderer.forceContextLoss() + this.renderer.domElement.remove() + this.callbacks = {} + } + // At most one pending frame. Static scenes sleep until an explicit change. + invalidate() { + if (!this.running) return + this.dirty = true + if (this.animationId === null && !document.hidden) + this.animationId = requestAnimationFrame(this.animate) + } + setActivity({ life = this.life, effects = this.effects }) { + if (this.life === life && this.effects === effects) return + this.life = life + this.effects = effects + this.refreshLighting() + this.syncLightingClock() + if (this.traffic) this.traffic.visible = life + this.previous = 0 + this.invalidate() + } + setAutoRotate(enabled) { + if (this.autoRotate === enabled) return + this.autoRotate = enabled + this.previous = 0 + this.invalidate() + } + refreshLighting() { + if (updateLighting(this)) this.invalidate() + } + setWeather(type = 'none') { + if (!['none', 'rain', 'snow'].includes(type)) + throw new RangeError(`Unknown weather type: ${type}`) + if (this.weather === type) return + this.weather = type + if (type !== 'none' && !this.weatherParticles) { + this.weatherParticles = new CityWeather() + this.scene.add(this.weatherParticles) + if (this.layout) this.weatherParticles.setLayout(this.layout) + } + this.invalidate() + } + setLightingMode(mode = 'auto') { + if (!['auto', 'day', 'twilight', 'night'].includes(mode)) + throw new RangeError(`Unknown lighting mode: ${mode}`) + if (this.lightingMode === mode) return + this.lightingMode = mode + this.refreshLighting() + this.syncLightingClock() + } + syncLightingClock() { + clearInterval(this.lightingTimer) + this.lightingTimer = + this.life && this.lightingMode === 'auto' + ? setInterval(() => { + if (!document.hidden) this.refreshLighting() + }, 60_000) + : null + } + animate(now) { + this.animationId = null + if (!this.running) return + if (document.hidden) { + this.previous = now + return + } + const dirty = this.dirty + this.dirty = false + const elapsedSeconds = (now - (this.previous || now)) / 1000 + const dt = Math.min(elapsedSeconds, 0.05) + this.previous = now + if (!this.reduceMotion) this.elapsed += dt + const flying = Boolean(this.fly) + if (this.fly) { + const f = this.fly + // A RAF timestamp can precede a transition created later in that frame. + // Negative progress overshoots the easing curve, especially at 1 ms. + let t = THREE.MathUtils.clamp( + (now - f.start) / (this.reduceMotion ? 1 : 850), + 0, + 1, + ) + t = t * t * (3 - 2 * t) + this.camera.position.lerpVectors(f.fromPosition, f.toPosition, t) + this.controls.target.lerpVectors(f.fromTarget, f.toTarget, t) + this.camera.zoom = THREE.MathUtils.lerp(f.fromZoom, f.toZoom, t) + this.camera.updateProjectionMatrix() + this.cameraRevision++ + if (t >= 1) this.fly = null + } + // Explicit opt-in also works with reduced motion. OrbitControls pauses + // while dragging; camera flights and pagination take precedence as well. + this.controls.autoRotate = Boolean( + this.autoRotate && !flying && this.controls.enabled, + ) + const cameraMoved = this.controls.update(elapsedSeconds) + this.controls.autoRotate = false + const lightingChanged = advanceLighting(this, now) + const trafficAnimated = !this.reduceMotion && this.life && this.simulation + const weatherAnimated = + !this.reduceMotion && this.life && this.weather !== 'none' + this.weatherParticles?.update( + this.elapsed, + this.weather, + weatherAnimated, + this.scene.background, + ) + const effectsAnimated = + !this.reduceMotion && + this.effects && + (this.emitters?.length || + this.warningSources?.length || + this.criticalSurfaces?.some((s) => s.items.length)) + if (trafficAnimated) this.updateTraffic(dt) + if (dirty || lightingChanged || effectsAnimated || cameraMoved) + this.updateEffects(this.elapsed) + this.camera.updateMatrixWorld() + this.callbacks.frame?.(this) + this.renderer.render(this.scene, this.camera) + if ( + this.fly || + (this.autoRotate && this.controls.enabled) || + this.lightingTransition || + cameraMoved || + trafficAnimated || + weatherAnimated || + effectsAnimated + ) + this.invalidate() + else this.previous = 0 + } +} diff --git a/packages/react-charts/src/city/engine/critical-blink.js b/packages/react-charts/src/city/engine/critical-blink.js new file mode 100644 index 00000000..6b5f26ab --- /dev/null +++ b/packages/react-charts/src/city/engine/critical-blink.js @@ -0,0 +1,20 @@ +import { random } from './model.js' + +// Stable per company, independent of frame rate, pagination and mesh order. +export function createCriticalBlink(companyId) { + let seed = 2166136261 + for (const character of String(companyId)) + seed = Math.imul(seed ^ character.codePointAt(0), 16777619) + const rng = random(seed), + ends = [] + let period = 0 + for (let i = 0; i < 32; i++) { + period += 0.45 + rng() * 0.65 + ends.push(period) + } + const offset = rng() * period + return (time) => { + const local = (((time + offset) % period) + period) % period + return ends.findIndex((end) => local < end) % 2 === 0 + } +} diff --git a/packages/react-charts/src/city/engine/landscape.js b/packages/react-charts/src/city/engine/landscape.js new file mode 100644 index 00000000..1fe40a8f --- /dev/null +++ b/packages/react-charts/src/city/engine/landscape.js @@ -0,0 +1,63 @@ +import { ROAD_EVERY, random } from './model.js' + +// Coordinate-seeded parks stay stable across renders, filters and pagination. +// Clearings and small clusters break up the grid without occupying built lots. +export function greenLots(layout) { + const occupied = new Set(layout.companies.map((c) => `${c.col},${c.row}`)) + const lots = [], + bounds = layout.roadBounds + for (let row = bounds.z1; row <= bounds.z2; row++) + for (let col = bounds.x1; col <= bounds.x2; col++) { + if ( + col % ROAD_EVERY === 0 || + row % ROAD_EVERY === 0 || + occupied.has(`${col},${row}`) + ) + continue + // Coordinate seeds keep the same park looking the same after rebuilding. + const rng = random(Math.imul(col + 59, 7349) ^ Math.imul(row + 41, 9157)) + const density = rng() + const count = + density < 0.18 ? 0 : density < 0.63 ? 1 : density < 0.92 ? 2 : 3 + const benches = [] + if (rng() < 0.09) { + const rotation = (Math.floor(rng() * 4) * Math.PI) / 2 + benches.push({ + dx: Math.sin(rotation) * 0.94, + dz: Math.cos(rotation) * 0.94, + rotation, + }) + } + const trees = [] + // Bounded rejection sampling leaves space around canopies and benches. + for (let attempt = 0; trees.length < count && attempt < 32; attempt++) { + const tree = { + dx: (rng() - 0.5) * 1.56, + dz: (rng() - 0.5) * 1.56, + scale: 0.32 + rng() * 0.18, + color: rng() > 0.5 ? '#789b59' : '#94af68', + } + if ( + trees.some( + (other) => + Math.hypot(tree.dx - other.dx, tree.dz - other.dz) < + tree.scale + other.scale + 0.08, + ) || + benches.some( + (bench) => + Math.hypot(tree.dx - bench.dx, tree.dz - bench.dz) < + tree.scale + 0.55, + ) + ) + continue + trees.push(tree) + } + lots.push({ + col, + row, + trees, + benches, + }) + } + return lots +} diff --git a/packages/react-charts/src/city/engine/lib/company-dates.js b/packages/react-charts/src/city/engine/lib/company-dates.js new file mode 100644 index 00000000..615fe9ce --- /dev/null +++ b/packages/react-charts/src/city/engine/lib/company-dates.js @@ -0,0 +1,63 @@ +// Source timestamps must identify an instant, including a timezone. +export function timestampValue(value) { + if (typeof value !== 'string') return NaN + const parts = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.exec( + value, + ) + if (!parts) return NaN + const [, year, month, day, hour, minute, second] = parts.map(Number) + const days = new Date(Date.UTC(year, month, 0)).getUTCDate() + if ( + month < 1 || + month > 12 || + day < 1 || + day > days || + hour > 23 || + minute > 59 || + second > 59 + ) + return NaN + return Date.parse(value) +} + +export function normalizeCompanyDates(record, index) { + const dates = {} + for (const key of ['updatedAt', 'statusChangedAt']) { + if (record[key] == null) continue + const timestamp = timestampValue(record[key]) + if (!Number.isFinite(timestamp)) + throw new Error( + `Empresa ${index + 1}: ${key} deve ser uma data ISO 8601 válida com fuso horário.`, + ) + dates[key] = new Date(timestamp).toISOString() + } + if ( + dates.updatedAt && + dates.statusChangedAt && + dates.statusChangedAt > dates.updatedAt + ) + throw new Error( + `Empresa ${index + 1}: statusChangedAt não pode ser posterior a updatedAt.`, + ) + return dates +} + +export function recentCriticalCompanies(companies) { + const critical = companies.filter((company) => company.status === 'critical') + const dated = critical.filter((company) => + Number.isFinite(timestampValue(company.statusChangedAt)), + ) + return { + companies: dated + .sort( + (a, b) => + timestampValue(b.statusChangedAt) - + timestampValue(a.statusChangedAt) || + String(a.id).localeCompare(String(b.id)), + ) + .slice(0, 4), + undatedCount: critical.length - dated.length, + total: critical.length, + } +} diff --git a/packages/react-charts/src/city/engine/lib/fullscreen-wake-lock.js b/packages/react-charts/src/city/engine/lib/fullscreen-wake-lock.js new file mode 100644 index 00000000..7201efd2 --- /dev/null +++ b/packages/react-charts/src/city/engine/lib/fullscreen-wake-lock.js @@ -0,0 +1,71 @@ +// Keep only this fullscreen map awake; release on exit, hiding or unmount. +export function maintainFullscreenWakeLock({ + document, + navigator, + element, + onStatus, +}) { + let disposed = false + let lock = null + let pending = false + let generation = 0 + const wanted = () => + !disposed && + Boolean(element()) && + document.fullscreenElement === element() && + document.visibilityState === 'visible' + const release = (sentinel) => { + if (sentinel) void sentinel.release().catch(() => {}) + } + const sync = async () => { + if (!wanted()) { + generation++ + const previous = lock + lock = null + release(previous) + if (!disposed) onStatus('idle') + return + } + if (lock || pending) return + if (!navigator.wakeLock?.request) { + onStatus('unavailable') + return + } + pending = true + const requestGeneration = generation + try { + const sentinel = await navigator.wakeLock.request('screen') + if (!wanted() || requestGeneration !== generation) { + release(sentinel) + } else { + lock = sentinel + onStatus(sentinel.released ? 'unavailable' : 'active') + sentinel.addEventListener( + 'release', + () => { + if (lock !== sentinel) return + lock = null + if (!disposed) onStatus(wanted() ? 'unavailable' : 'idle') + }, + { once: true }, + ) + if (sentinel.released) lock = null + } + } catch { + if (wanted() && requestGeneration === generation) onStatus('unavailable') + } finally { + pending = false + // A hide/show or fullscreen exit/re-entry happened during the request. + if (!disposed && requestGeneration !== generation) void sync() + } + } + document.addEventListener('fullscreenchange', sync) + document.addEventListener('visibilitychange', sync) + void sync() + return () => { + disposed = true + document.removeEventListener('fullscreenchange', sync) + document.removeEventListener('visibilitychange', sync) + void sync() + } +} diff --git a/packages/react-charts/src/city/engine/lib/map-compass.js b/packages/react-charts/src/city/engine/lib/map-compass.js new file mode 100644 index 00000000..14808671 --- /dev/null +++ b/packages/react-charts/src/city/engine/lib/map-compass.js @@ -0,0 +1,6 @@ +// North is -Z on the city's ground plane. Express it in camera space so +// the arrow follows the visible streets, including their isometric tilt. +export function mapNorthAngle(camera) { + const matrix = camera.matrixWorldInverse.elements + return Math.atan2(-matrix[8], -matrix[9]) +} diff --git a/packages/react-charts/src/city/engine/lib/map-tooltip.js b/packages/react-charts/src/city/engine/lib/map-tooltip.js new file mode 100644 index 00000000..7bde32f4 --- /dev/null +++ b/packages/react-charts/src/city/engine/lib/map-tooltip.js @@ -0,0 +1,23 @@ +// Keep the roof anchor available while a visible facade is hovered, even when +// the roof itself projects beyond the map after zooming or panning. +export function mapTooltipAnchor(point, width, height) { + if ( + !Number.isFinite(point.x) || + !Number.isFinite(point.y) || + width <= 0 || + height <= 0 + ) + return null + const insetX = Math.min(18, width / 2) + const insetY = Math.min(18, height / 2) + return { + x: Math.max( + insetX, + Math.min((point.x * 0.5 + 0.5) * width, width - insetX), + ), + y: Math.max( + insetY, + Math.min((-point.y * 0.5 + 0.5) * height, height - insetY), + ), + } +} diff --git a/packages/react-charts/src/city/engine/model.js b/packages/react-charts/src/city/engine/model.js new file mode 100644 index 00000000..a9c0f9bf --- /dev/null +++ b/packages/react-charts/src/city/engine/model.js @@ -0,0 +1,430 @@ +import { normalizeCompanyDates } from './lib/company-dates.js' + +export const REGIONS = [ + { + mask: 1, + label: 'Category A', + shortLabel: 'A', + color: '#4b91ef', + name: 'Only Category A', + }, + { + mask: 2, + label: 'Category B', + shortLabel: 'B', + color: '#e281af', + name: 'Only Category B', + }, + { + mask: 4, + label: 'Category C', + shortLabel: 'C', + color: '#ecc456', + name: 'Only Category C', + }, + { + mask: 3, + label: 'Category A + Category B', + shortLabel: 'A + B', + color: '#ab7cdd', + name: 'Category A e Category B', + }, + { + mask: 5, + label: 'Category A + Category C', + shortLabel: 'A + C', + color: '#47b8b2', + name: 'Category A e Category C', + }, + { + mask: 6, + label: 'Category B + Category C', + shortLabel: 'B + C', + color: '#e99b55', + name: 'Category B e Category C', + }, + { + mask: 7, + label: 'Category A + Category B + Category C', + shortLabel: 'A + B + C', + color: '#7777ca', + name: 'Todos os sistemas', + }, +] +export const STATUS = { + healthy: { label: 'Saudável', color: '#4c9878' }, + warning: { label: 'Risco médio', color: '#c28b29' }, + critical: { label: 'Alto risco', color: '#db654d' }, +} +export function random(seed) { + let s = seed >>> 0 + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0 + return s / 4294967296 + } +} +export function membership(x, z) { + return ( + (x >= -18 && x <= 18 && z >= -23 && z <= 7 ? 1 : 0) | + (x >= -29 && x <= 5 && z >= -6 && z <= 26 ? 2 : 0) | + (x >= -5 && x <= 29 && z >= -6 && z <= 26 ? 4 : 0) + ) +} +export function isPark(x, z) { + const n = Math.imul(x + 59, 7349) ^ Math.imul(z + 41, 9157) + return (n >>> 0) % 100 < 9 +} +export function lots() { + const result = [] + for (let z = -23; z <= 26; z++) + for (let x = -29; x <= 29; x++) { + const mask = membership(x, z) + if (mask && x % 6 !== 0 && z % 6 !== 0 && !isPark(x, z)) + result.push({ x: x * 2.8, z: z * 2.8, mask, col: x, row: z }) + } + return result +} +const names = [ + 'Aurora', + 'Horizonte', + 'Nexo', + 'Vértice', + 'Alameda', + 'Órbita', + 'Cedro', + 'Prisma', + 'Farol', + 'Solare', + 'Brisa', + 'Íris', + 'Ponte', + 'Lume', + 'Atlas', + 'Verde', +] +const sectors = [ + 'Tecnologia', + 'Logística', + 'Educação', + 'Saúde', + 'Varejo', + 'Serviços', + 'Indústria', + 'Finanças', +] +export function demoCompanies(count = 1400) { + const rng = random(8419) + const pool = lots() + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + ;[pool[i], pool[j]] = [pool[j], pool[i]] + } + const chosen = pool.slice(0, Math.min(count, pool.length)) + return chosen.map((lot, i) => { + const risk = rng() + return { + id: `empresa-${i + 1}`, + name: `${names[i % names.length]} ${sectors[Math.floor(i / names.length) % sectors.length]} ${String(i + 1).padStart(4, '0')}`, + users: Math.round(8 + Math.pow(rng(), 3) * 4200), + status: risk < 0.024 ? 'critical' : risk < 0.1 ? 'warning' : 'healthy', + ...lot, + } + }) +} +// Share one domain across the complete dataset, including filtered/paginated rows. +export function metricDomainFor(records) { + let min = Infinity, + max = -Infinity + for (const record of records) { + const value = metricFor(record).value + min = Math.min(min, value) + max = Math.max(max, value) + } + return records.length ? [min, max] : [0, 0] +} + +// Standalone legacy callers can omit the domain; cities always supply their data domain. +export function sizeForValue(value, [min, max] = [0, 4200]) { + const ratio = + max === min + ? 0.5 + : Math.sqrt(Math.max(0, Math.min(1, (value - min) / (max - min)))) + const height = 0.95 + ratio * (16.8 - 0.95) + const floors = Math.min(14, Math.ceil(height / 1.2)) + return { height, floors, house: floors === 1 } +} +// Compatibility for integrations using the previous helper name. +export const sizeForUsers = sizeForValue +export function filteredCompanies( + companies, + { region = 'all', status = 'all', query = '' } = {}, +) { + const q = query.trim().toLocaleLowerCase('pt-BR') + // Keep single-status callers compatible; an empty selection means all. + const statuses = Array.isArray(status) + ? status + : status === 'all' + ? [] + : [status] + return companies.filter( + (c) => + (region === 'all' || c.mask === Number(region)) && + (!statuses.length || statuses.includes(c.status)) && + (!q || c.name.toLocaleLowerCase('pt-BR').includes(q)), + ) +} +// Legacy records remain readable; all visual consumers use the generic metric. +export function metricFor(record) { + return record.metric ?? { label: 'Usuários', value: record.users } +} +export function categoryLabel(record) { + return ( + record.categories?.map((category) => category.label).join(' + ') ?? + REGIONS.find((region) => region.mask === record.mask)?.label ?? + '' + ) +} +export function metricLabel(records) { + const labels = new Set(records.map((record) => metricFor(record).label)) + return labels.size === 1 ? [...labels][0] : 'Valor' +} +export function regionsFor(records) { + const labels = new Map() + for (const record of records) { + const bits = [1, 2, 4].filter((bit) => record.mask & bit) + record.categories?.forEach((category, index) => + labels.set(bits[index], category.label), + ) + } + if (!labels.size) return records.length ? REGIONS : [] + return REGIONS.filter((region) => + [1, 2, 4].every((bit) => !(region.mask & bit) || labels.has(bit)), + ).map((region) => { + const label = [1, 2, 4] + .filter((bit) => region.mask & bit) + .map((bit) => labels.get(bit)) + .join(' + ') + return { ...region, label, shortLabel: label, name: label } + }) +} +export function placeCompanies(records) { + if (!Array.isArray(records) || !records.length) + throw new Error('O arquivo deve conter uma lista de empresas.') + const ids = new Set() + const labels = new Map() + let hasLegacy = false + const normalized = records.map((r, i) => { + if (!r || typeof r.name !== 'string' || !r.name.trim()) + throw new Error(`Empresa ${i + 1}: informe um nome.`) + const metric = metricFor(r) + if ( + typeof metric.label !== 'string' || + !metric.label.trim() || + !Number.isFinite(metric.value) || + metric.value < 0 + ) + throw new Error( + `Empresa ${i + 1}: metric deve conter label e value numérico não negativo.`, + ) + let categories = r.categories + if (categories === undefined) { + hasLegacy = true + if ( + !Array.isArray(r.systems) || + !r.systems.length || + r.systems.some((value) => !['A', 'B', 'C'].includes(value)) + ) + throw new Error(`Empresa ${i + 1}: sistemas deve conter A, B ou C.`) + categories = [...new Set(r.systems)].map((value) => ({ + value, + label: REGIONS.find( + (region) => region.mask === { A: 1, B: 2, C: 4 }[value], + ).label, + })) + } + if (!Array.isArray(categories) || !categories.length) + throw new Error(`Empresa ${i + 1}: informe ao menos uma categoria.`) + const unique = new Map() + for (const category of categories) { + if ( + !category || + typeof category.value !== 'string' || + !category.value.trim() || + typeof category.label !== 'string' || + !category.label.trim() + ) + throw new Error( + `Empresa ${i + 1}: cada categoria deve conter label e value textuais não vazios.`, + ) + const value = category.value.trim(), + label = category.label.trim() + if (labels.has(value) && labels.get(value) !== label) + throw new Error(`Categoria ${value}: use o mesmo label em toda a base.`) + labels.set(value, label) + unique.set(value, { value, label }) + } + if (!Object.hasOwn(STATUS, r.status)) + throw new Error(`Empresa ${i + 1}: status inválido.`) + const id = String(r.id ?? `importada-${i + 1}`) + if (!id.trim() || ids.has(id)) + throw new Error('Há identificadores vazios ou repetidos no arquivo.') + ids.add(id) + return { + id, + name: r.name.trim(), + metric: { label: metric.label.trim(), value: metric.value }, + categories: [...unique.values()], + status: r.status, + ...normalizeCompanyDates(r, i), + ...(r.metric === undefined ? { users: r.users } : {}), + } + }) + const values = hasLegacy + ? [...new Set(['A', 'B', 'C', ...labels.keys()])] + : [...labels.keys()].sort() + if (values.length > 3) + throw new Error('O mapa aceita até três categorias distintas por base.') + const bits = new Map(values.map((value, index) => [value, 1 << index])) + return normalized.map((record) => ({ + ...record, + categories: record.categories.sort( + (a, b) => bits.get(a.value) - bits.get(b.value), + ), + mask: record.categories.reduce( + (mask, category) => mask | bits.get(category.value), + 0, + ), + })) +} +export const PAGE_SIZE = 100 +export const PAGE_SIZES = [100, 250, 500, 1000, 'all'] +export const ROAD_EVERY = 4 +export function paginateCompanies( + companies, + filters = {}, + page = 1, + pageSize = PAGE_SIZE, +) { + const matching = filteredCompanies(companies, filters), + total = matching.length + if (!PAGE_SIZES.includes(pageSize)) + throw new Error('Tamanho de página inválido.') + const limit = pageSize === 'all' ? Math.max(1, total) : pageSize + const pages = Math.max(1, Math.ceil(total / limit)) + const current = Math.max(1, Math.min(pages, Math.floor(page) || 1)) + return { + companies: matching.slice((current - 1) * limit, current * limit), + total, + pages, + page: current, + pageSize, + start: total ? (current - 1) * limit + 1 : 0, + end: Math.min(current * limit, total), + } +} +function boundsFor(p) { + return { + a: { x1: -p.cx - p.ab, x2: p.cx + p.ac, z1: -p.cz - p.a, z2: p.cz }, + b: { x1: -p.cx - p.ab - p.b, x2: p.cx, z1: -p.cz, z2: p.cz + p.bc }, + c: { x1: -p.cx, x2: p.cx + p.ac + p.c, z1: -p.cz, z2: p.cz + p.bc }, + } +} +export function layoutMembership(x, z, bounds) { + const inRect = (r) => x >= r.x1 && x <= r.x2 && z >= r.z1 && z <= r.z2 + return ( + (inRect(bounds.a) ? 1 : 0) | + (inRect(bounds.b) ? 2 : 0) | + (inRect(bounds.c) ? 4 : 0) + ) +} +export function layoutCity(records) { + const needed = new Map( + REGIONS.map((r) => [ + r.mask, + records.filter((c) => c.mask === r.mask).length, + ]), + ) + const core = Math.max( + 1, + Math.ceil(Math.sqrt(Math.max(0, ...needed.values())) / 2), + ) + const p = { cx: core, cz: core, a: 1, b: 1, c: 1, ab: 1, ac: 1, bc: 1 } + let bounds, buckets + for (let attempt = 0; attempt < 250; attempt++) { + bounds = boundsFor(p) + buckets = new Map(REGIONS.map((r) => [r.mask, []])) + for (let z = bounds.a.z1; z <= bounds.b.z2; z++) + for (let x = bounds.b.x1; x <= bounds.c.x2; x++) { + const mask = layoutMembership(x, z, bounds) + if ( + mask && + x % ROAD_EVERY !== 0 && + z % ROAD_EVERY !== 0 && + !isPark(x, z) + ) + buckets + .get(mask) + .push({ x: x * 2.8, z: z * 2.8, col: x, row: z, mask }) + } + const missing = REGIONS.filter( + (r) => buckets.get(r.mask).length < needed.get(r.mask), + ) + if (!missing.length) break + for (const r of missing) { + if (r.mask === 7) { + p.cx++ + p.cz++ + } else p[{ 1: 'a', 2: 'b', 4: 'c', 3: 'ab', 5: 'ac', 6: 'bc' }[r.mask]]++ + } + } + // Within each exact intersection, populate the nearest free lots first. + for (const pool of buckets.values()) + pool.sort( + (a, b) => + a.col * a.col + a.row * a.row - (b.col * b.col + b.row * b.row) || + a.row - b.row || + a.col - b.col, + ) + const placed = records.map((record) => { + const lot = buckets.get(record.mask).shift() + if (!lot) throw new Error('Não foi possível distribuir esta página.') + return { ...record, ...lot } + }) + const roadBounds = placed.length + ? { + x1: + Math.floor((Math.min(...placed.map((c) => c.col)) - 2) / ROAD_EVERY) * + ROAD_EVERY, + x2: + Math.ceil((Math.max(...placed.map((c) => c.col)) + 2) / ROAD_EVERY) * + ROAD_EVERY, + z1: + Math.floor((Math.min(...placed.map((c) => c.row)) - 2) / ROAD_EVERY) * + ROAD_EVERY, + z2: + Math.ceil((Math.max(...placed.map((c) => c.row)) + 2) / ROAD_EVERY) * + ROAD_EVERY, + } + : { x1: -4, x2: 4, z1: -4, z2: 4 } + const anchors = {} + for (const r of REGIONS) { + const group = placed.filter((c) => c.mask === r.mask) + if (group.length) + anchors[r.mask] = [ + group.reduce((v, c) => v + c.x, 0) / group.length, + 2, + group.reduce((v, c) => v + c.z, 0) / group.length, + ] + } + return { + companies: placed, + metricDomain: metricDomainFor(records), + bounds, + roadBounds, + anchors, + center: [ + (roadBounds.x1 + roadBounds.x2) * 1.4, + (roadBounds.z1 + roadBounds.z2) * 1.4, + ], + } +} diff --git a/packages/react-charts/src/city/engine/plume-renderer.js b/packages/react-charts/src/city/engine/plume-renderer.js new file mode 100644 index 00000000..2d376c4c --- /dev/null +++ b/packages/react-charts/src/city/engine/plume-renderer.js @@ -0,0 +1,116 @@ +import * as THREE from 'three' +import { makeParticles, samplePlume } from './plumes.js' + +const vertexShader = ` +attribute float aAlpha;attribute float aHeat;attribute float aSeed; +varying float vAlpha;varying float vHeat;varying float vSeed; +varying vec3 vNormal;varying vec3 vView;varying vec3 vLocal; +void main(){ + vAlpha=aAlpha;vHeat=aHeat;vSeed=aSeed;vLocal=position; + vec4 mv=modelViewMatrix*instanceMatrix*vec4(position,1.); + vec3 s=vec3(dot(instanceMatrix[0].xyz,instanceMatrix[0].xyz),dot(instanceMatrix[1].xyz,instanceMatrix[1].xyz),dot(instanceMatrix[2].xyz,instanceMatrix[2].xyz)); + vNormal=normalMatrix*mat3(instanceMatrix)*(normal/max(s,vec3(.000001)));vView=-mv.xyz; + gl_Position=projectionMatrix*mv; +}` +const fragmentShader = ` +uniform float uTime;uniform bool uSmoke; +varying float vAlpha;varying float vHeat;varying float vSeed; +varying vec3 vNormal;varying vec3 vView;varying vec3 vLocal; +void main(){ + float facing=pow(max(0.,dot(normalize(vNormal),normalize(vView))),.65); + float billow=.73+.27*sin(vLocal.x*8.+vSeed+uTime*.7)*sin(vLocal.z*7.-vLocal.y*5.+vSeed); + float alpha=vAlpha*facing*billow; + if(alpha<.005)discard; + vec3 color; + if(uSmoke){float shade=mix(.035,.55,vHeat);color=vec3(shade*.97,shade,shade*.98)*(1.-.13*vLocal.y);} + else{float core=clamp(vHeat*.78+facing*.32,0.,1.);color=mix(vec3(.98,.10,.006),vec3(1.,.93,.53),core*core);} + gl_FragColor=vec4(color,alpha); + #include + #include +}` + +export class WindowPlumes { + constructor(emitters, parent) { + this.parent = parent + this.layers = [] + this.dummy = new THREE.Object3D() + this.velocity = new THREE.Vector3() + this.up = new THREE.Vector3(0, 1, 0) + for (const kind of ['flame', 'smoke']) { + const particles = makeParticles(emitters, kind), + geometry = new THREE.OctahedronGeometry(1, 0) + const n = Math.max(1, particles.length) + geometry.setAttribute( + 'aAlpha', + new THREE.InstancedBufferAttribute(new Float32Array(n), 1).setUsage( + THREE.DynamicDrawUsage, + ), + ) + geometry.setAttribute( + 'aHeat', + new THREE.InstancedBufferAttribute(new Float32Array(n), 1).setUsage( + THREE.DynamicDrawUsage, + ), + ) + geometry.setAttribute( + 'aSeed', + new THREE.InstancedBufferAttribute(new Float32Array(n), 1), + ) + particles.forEach((p, i) => (geometry.attributes.aSeed.array[i] = p.seed)) + const material = new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + transparent: true, + depthWrite: false, + depthTest: true, + toneMapped: false, + uniforms: { uTime: { value: 0 }, uSmoke: { value: kind === 'smoke' } }, + }) + const mesh = new THREE.InstancedMesh(geometry, material, n) + mesh.count = particles.length + mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage) + mesh.frustumCulled = false + mesh.renderOrder = kind === 'smoke' ? 4 : 3 + parent.add(mesh) + this.layers.push({ kind, particles, mesh }) + } + } + update(time, visible, active) { + for (const layer of this.layers) { + const { mesh, particles, kind } = layer + mesh.visible = visible + if (!visible) continue + mesh.material.uniforms.uTime.value = time + const alpha = mesh.geometry.attributes.aAlpha, + heat = mesh.geometry.attributes.aHeat + particles.forEach((particle, i) => { + const age = + (time + particle.phase + particle.source.seed) % particle.life + const state = samplePlume(particle.source, age, particle.seed, kind) + this.dummy.position.set(state.x, state.y, state.z) + if (kind === 'flame') { + this.velocity.set(state.vx, state.vy, state.vz).normalize() + this.dummy.quaternion.setFromUnitVectors(this.up, this.velocity) + } else + this.dummy.rotation.set( + particle.seed + time * 0.06, + particle.seed * 0.7, + particle.seed * 0.3, + ) + this.dummy.scale.set(state.radius, state.length, state.radius) + this.dummy.updateMatrix() + mesh.setMatrixAt(i, this.dummy.matrix) + alpha.array[i] = active.has(particle.source.company) ? state.alpha : 0 + heat.array[i] = + kind === 'smoke' + ? particle.source.critical + ? 0.12 + (age / particle.life) * 0.36 + : 0.82 + : state.heat + }) + mesh.instanceMatrix.needsUpdate = true + alpha.needsUpdate = true + heat.needsUpdate = true + } + } +} diff --git a/packages/react-charts/src/city/engine/plumes.js b/packages/react-charts/src/city/engine/plumes.js new file mode 100644 index 00000000..3c44e813 --- /dev/null +++ b/packages/react-charts/src/city/engine/plumes.js @@ -0,0 +1,145 @@ +const fract = (n) => n - Math.floor(n) +export const hash = (n) => fract(Math.sin(n * 127.1 + 311.7) * 43758.5453) + +export function facadeWindows(company, size, width, depth) { + const windows = [] + for (let floor = 0; floor < size.floors; floor++) + for (let j = 0; j < 2; j++) { + const offset = (j - 0.5) * 0.62, + y = 0.52 + floor * (company.height / size.floors) + windows.push({ + index: windows.length, + x: company.x + offset, + y, + z: company.z + depth / 2, + nx: 0, + nz: 1, + width: 0.25, + height: 0.35, + }) + windows.push({ + index: windows.length, + x: company.x + width / 2, + y, + z: company.z + offset, + nx: 1, + nz: 0, + width: 0.25, + height: 0.35, + }) + } + return windows +} + +export function windowEmitters(company, windows) { + if (company.status === 'healthy') return [] + const critical = company.status === 'critical' + const floors = new Set(windows.map((w) => w.y)).size + const count = Math.min( + windows.length, + critical + ? company.house + ? 2 + : 6 + : company.house + ? 1 + : Math.max(2, Math.ceil(floors * 0.75)), + ) + return Array.from({ length: count }, (_, i) => { + const index = critical + ? Math.floor((i * windows.length) / count) + (i % 2) + : Math.floor((i * (floors - 1)) / Math.max(1, count - 1)) * 4 + (i % 4) + const aperture = windows[Math.min(windows.length - 1, index)] + // Origin is behind the facade; the building depth test hides the interior. + const seed = + hash(company.x * 13 + company.z * 71 + aperture.index * 17) * 100 + return { + ...aperture, + company: company.id, + critical, + seed, + depth: 0.075, + flameLife: 1.25, + smokeLife: critical ? 5.2 : 5.8, + rate: aperture.width * aperture.height * (critical ? 225 : 34), + } + }) +} + +// An inexpensive buoyant jet approximation: pressure drives the initial outward +// velocity, drag dissipates it, buoyancy bends the stream upward. This is not CFD. +// All births fit the aperture; expansion starts only outside the opening. +export function samplePlume(emitter, age, seed, kind = 'flame') { + const smoke = kind === 'smoke', + critical = emitter.critical + const life = smoke ? emitter.smokeLife : emitter.flameLife, + t = Math.max(0, Math.min(age, life)) + const a = hash(seed + 11), + b = hash(seed + 23), + c = hash(seed + 37) + const drag = smoke ? 0.75 : 1.15, + velocity = (critical ? 1.05 : 0.55) * (0.8 + a * 0.4) + const out = -emitter.depth + (velocity * (1 - Math.exp(-drag * t))) / drag + const outside = Math.max(0, out) + const force = smoke ? (critical ? 1.65 : 0.92) : 3.2 + const rise = + 0.07 * t + force * (t / drag - (1 - Math.exp(-drag * t)) / (drag * drag)) + const apertureRadius = Math.min(emitter.width, emitter.height) * 0.13 + const initialSide = (b - 0.5) * (emitter.width - 2 * apertureRadius) * 0.66 + const initialY = (c - 0.5) * (emitter.height - 2 * apertureRadius) * 0.55 + let tangent = + initialSide + + outside * Math.sin(t * (smoke ? 3.1 : 11) + seed) * (smoke ? 0.22 : 0.1) + let y = initialY + rise + if (out < 0.035) { + tangent = Math.max( + -emitter.width * 0.34, + Math.min(emitter.width * 0.34, tangent), + ) + y = Math.max(-emitter.height * 0.3, Math.min(emitter.height * 0.3, y)) + } + const expansion = smoke + ? outside * 0.27 + Math.max(0, t - 0.35) * 0.15 + : outside * 0.16 * Math.sin(Math.PI * Math.min(0.98, t / life)) + const radius = out < 0.035 ? apertureRadius : apertureRadius + expansion + const wind = smoke ? Math.pow(Math.max(0, t - 0.15), 1.35) : 0 + const heat = Math.pow(Math.max(0, 1 - t / life), 0.65) + const alpha = + (smoke ? (critical ? 0.35 : 0.23) : 0.84) * + Math.min(1, t / (smoke ? 0.18 : 0.055)) * + Math.pow(Math.max(0, 1 - t / life), smoke ? 0.7 : 0.6) + return { + x: emitter.x + emitter.nx * out - emitter.nz * tangent + wind * 0.035, + y: emitter.y + y, + z: emitter.z + emitter.nz * out + emitter.nx * tangent + wind * 0.018, + radius, + length: out < 0.035 ? apertureRadius : radius * (smoke ? 1.1 : 2.4), + alpha, + heat, + vx: emitter.nx * velocity * Math.exp(-drag * t), + vy: 0.07 + (force * (1 - Math.exp(-drag * t))) / drag, + vz: emitter.nz * velocity * Math.exp(-drag * t), + outward: out, + } +} + +export function makeParticles(emitters, kind) { + const smoke = kind === 'smoke', + particles = [] + for (const source of emitters) { + if (!smoke && !source.critical) continue + const life = smoke ? source.smokeLife : source.flameLife + const rate = smoke + ? source.width * source.height * (source.critical ? 55 : 45) + : source.rate + const count = Math.max(8, Math.ceil(rate * life)) + for (let i = 0; i < count; i++) + particles.push({ + source, + seed: source.seed + i * 3.17, + phase: (i / count) * life, + life, + }) + } + return particles +} diff --git a/packages/react-charts/src/city/engine/scene-resources.js b/packages/react-charts/src/city/engine/scene-resources.js new file mode 100644 index 00000000..1afbbd52 --- /dev/null +++ b/packages/react-charts/src/city/engine/scene-resources.js @@ -0,0 +1,26 @@ +// Shared geometries/materials/textures are released once, before the next city. +export function disposeSceneGroups(groups) { + const geometries = new Set(), + materials = new Set(), + textures = new Set() + for (const group of groups) { + if (!group) continue + group.traverse((object) => { + object.dispose?.() + if (object.geometry) geometries.add(object.geometry) + for (const material of object.material + ? Array.isArray(object.material) + ? object.material + : [object.material] + : []) { + materials.add(material) + for (const value of Object.values(material)) + if (value?.isTexture) textures.add(value) + } + }) + group.clear() + } + for (const texture of textures) texture.dispose() + for (const material of materials) material.dispose() + for (const geometry of geometries) geometry.dispose() +} diff --git a/packages/react-charts/src/city/engine/selection-highlight.js b/packages/react-charts/src/city/engine/selection-highlight.js new file mode 100644 index 00000000..c26985b1 --- /dev/null +++ b/packages/react-charts/src/city/engine/selection-highlight.js @@ -0,0 +1,54 @@ +import * as THREE from 'three' + +// Reuse one highlight for every selection, without recoloring company/status data. +export class SelectionHighlight extends THREE.Group { + constructor() { + super() + this.visible = false + this.box = new THREE.BoxGeometry(1, 1, 1) + this.shells = [] + for (const [color, padding, order] of [ + ['#123441', 0.22, 1], + ['#ffffff', 0.12, 2], + ]) { + const material = new THREE.MeshBasicMaterial({ + color, + side: THREE.BackSide, + depthWrite: false, + toneMapped: false, + }) + const body = new THREE.Mesh(this.box, material) + body.renderOrder = order + this.add(body) + this.shells.push({ body, padding }) + } + for (const [color, inner, outer, y] of [ + ['#123441', 1.58, 1.94, 0.17], + ['#ffffff', 1.66, 1.85, 0.18], + ]) { + const ring = new THREE.Mesh( + new THREE.RingGeometry(inner, outer, 4), + new THREE.MeshBasicMaterial({ + color, + side: THREE.DoubleSide, + toneMapped: false, + }), + ) + ring.rotation.set(-Math.PI / 2, 0, Math.PI / 4) + ring.position.y = y + this.add(ring) + } + } + select(company) { + this.position.set(company.x, 0, company.z) + this.visible = true + for (const { body, padding } of this.shells) { + body.position.set(0, company.height / 2 + 0.13, 0) + body.scale.set( + company.width + padding, + company.height + padding, + company.depth + padding, + ) + } + } +} diff --git a/packages/react-charts/src/city/engine/traffic.js b/packages/react-charts/src/city/engine/traffic.js new file mode 100644 index 00000000..a453c8c5 --- /dev/null +++ b/packages/react-charts/src/city/engine/traffic.js @@ -0,0 +1,331 @@ +import { random, ROAD_EVERY } from './model.js' + +export const ROAD = { + step: 2.8, + width: 2.15, + lane: 0.5, + sidewalk: 1.29, + crosswalk: 1.8, + stop: 3.42, + gateHalf: 2.75, + carLength: 0.95, + carWidth: 0.58, + minGap: 2.0, +} +const mod = (n, d) => ((n % d) + d) % d +const clamp = (v, a, b) => Math.min(b, Math.max(a, v)) + +// The intersection has a full horizontal strip and two non-overlapping vertical +// connectors. Every street meets the next tile at its full 2.8-unit edge. +export function roadTileParts(col, row) { + const vertical = col % ROAD_EVERY === 0, + horizontal = row % ROAD_EVERY === 0 + if (!vertical && !horizontal) return [] + if (vertical && horizontal) { + const gap = (ROAD.step - ROAD.width) / 2 + return [ + { dx: 0, dz: 0, w: ROAD.step, d: ROAD.width }, + { dx: 0, dz: (ROAD.width + gap) / 2, w: ROAD.width, d: gap }, + { dx: 0, dz: -(ROAD.width + gap) / 2, w: ROAD.width, d: gap }, + ] + } + return [ + { + dx: 0, + dz: 0, + w: vertical ? ROAD.width : ROAD.step, + d: horizontal ? ROAD.width : ROAD.step, + }, + ] +} + +export function signalState(time) { + const t = mod(time, 42) + if (t < 9) return { x: 'green', z: 'red', walk: false, remaining: 9 - t } + if (t < 11) return { x: 'amber', z: 'red', walk: false, remaining: 11 - t } + if (t < 16) return { x: 'red', z: 'red', walk: false, remaining: 16 - t } + if (t < 25) return { x: 'red', z: 'green', walk: false, remaining: 25 - t } + if (t < 27) return { x: 'red', z: 'amber', walk: false, remaining: 27 - t } + if (t < 32) return { x: 'red', z: 'red', walk: false, remaining: 32 - t } + if (t < 39) return { x: 'red', z: 'red', walk: true, remaining: 39 - t } + return { x: 'red', z: 'red', walk: false, remaining: 42 - t } +} + +export function makeLoop(x0, z0, x1, z1, offset = ROAD.lane, radius = 0.75) { + const l = x0 + offset, + r = x1 - offset, + t = z0 + offset, + b = z1 - offset + const w = r - l - 2 * radius, + h = b - t - 2 * radius, + arc = (radius * Math.PI) / 2 + const segments = [ + { length: w, x: l + radius, z: t, dx: 1, dz: 0 }, + { length: arc, cx: r - radius, cz: t + radius, angle: -Math.PI / 2 }, + { length: h, x: r, z: t + radius, dx: 0, dz: 1 }, + { length: arc, cx: r - radius, cz: b - radius, angle: 0 }, + { length: w, x: r - radius, z: b, dx: -1, dz: 0 }, + { length: arc, cx: l + radius, cz: b - radius, angle: Math.PI / 2 }, + { length: h, x: l, z: b - radius, dx: 0, dz: -1 }, + { length: arc, cx: l + radius, cz: t + radius, angle: Math.PI }, + ] + let distance = 0 + const corners = [] + for (let i = 0; i < segments.length; i++) { + segments[i].start = distance + if (i % 2) corners.push(distance + arc / 2) + distance += segments[i].length + } + return { segments, radius, length: distance, corners, x0, z0, x1, z1 } +} + +export function loopPoint(loop, distance) { + const s = mod(distance, loop.length) + const segment = + loop.segments.find((e) => s < e.start + e.length) || loop.segments.at(-1) + const u = s - segment.start + if (segment.cx !== undefined) { + const a = segment.angle + u / loop.radius + return { + x: segment.cx + Math.cos(a) * loop.radius, + z: segment.cz + Math.sin(a) * loop.radius, + ry: Math.atan2(-Math.sin(a), Math.cos(a)), + } + } + return { + x: segment.x + segment.dx * u, + z: segment.z + segment.dz * u, + ry: Math.atan2(segment.dx, segment.dz), + } +} + +export class TrafficSimulation { + constructor(bounds, seed = 529) { + this.time = 0 + this.intersections = [] + this.routes = [] + this.cars = [] + this.walkers = [] + this.rng = random(seed) + const nodes = new Map(), + key = (x, z) => `${x},${z}` + for (let z = bounds.z1; z <= bounds.z2; z += ROAD_EVERY) + for (let x = bounds.x1; x <= bounds.x2; x += ROAD_EVERY) { + const node = { + id: key(x, z), + x: x * ROAD.step, + z: z * ROAD.step, + owner: null, + pedestrians: 0, + } + nodes.set(node.id, node) + this.intersections.push(node) + } + const blocks = [] + for (let z = bounds.z1; z < bounds.z2; z += ROAD_EVERY) + for (let x = bounds.x1; x < bounds.x2; x += ROAD_EVERY) + blocks.push({ x, z }) + for (let i = blocks.length - 1; i > 0; i--) { + const j = Math.floor(this.rng() * (i + 1)) + ;[blocks[i], blocks[j]] = [blocks[j], blocks[i]] + } + for (const { x, z } of blocks.slice(0, 18)) { + const loop = makeLoop( + x * ROAD.step, + z * ROAD.step, + (x + ROAD_EVERY) * ROAD.step, + (z + ROAD_EVERY) * ROAD.step, + ) + loop.nodes = [ + nodes.get(key(x + ROAD_EVERY, z)), + nodes.get(key(x + ROAD_EVERY, z + ROAD_EVERY)), + nodes.get(key(x, z + ROAD_EVERY)), + nodes.get(key(x, z)), + ] + loop.gates = loop.corners.map((center, i) => ({ + entry: mod(center - ROAD.gateHalf, loop.length), + node: loop.nodes[i], + axis: i % 2 ? 'z' : 'x', + })) + this.routes.push(loop) + for (let j = 0; j < 2; j++) { + const s = loop.segments[0].length * 0.4 + (j * loop.length) / 2 + const car = { + id: this.cars.length, + route: loop, + s, + speed: 0, + maxSpeed: 1.65 + this.rng() * 0.35, + nextGate: j * 2, + reservation: null, + distance: 0, + } + Object.assign(car, loopPoint(loop, s)) + this.cars.push(car) + } + const footLoop = makeLoop( + x * ROAD.step, + z * ROAD.step, + (x + ROAD_EVERY) * ROAD.step, + (z + ROAD_EVERY) * ROAD.step, + ROAD.sidewalk, + 0.12, + ) + for (let j = 0; j < 2; j++) { + const s = this.rng() * footLoop.length + this.walkers.push({ + kind: 'stroll', + route: footLoop, + s, + speed: 0.26 + this.rng() * 0.16, + ...loopPoint(footLoop, s), + }) + } + } + // A pedestrian scramble phase: cars are red in both directions. Pedestrians + // only enter when the whole junction is clear, and retain it until exit. + const crossingNodes = [...this.intersections] + for (let i = 0; i < Math.min(24, crossingNodes.length); i++) { + const node = + crossingNodes[ + Math.floor( + (i * crossingNodes.length) / Math.min(24, crossingNodes.length), + ) + ] + const axis = i % 2 ? 'x' : 'z' + const edge = + axis === 'z' + ? node.x >= bounds.x2 * ROAD.step + ? -1 + : 1 + : node.z >= bounds.z2 * ROAD.step + ? -1 + : 1 + const walker = { + kind: 'cross', + node, + axis, + edge, + progress: 0, + direction: 1, + state: 'waiting', + wait: this.rng() * 7, + speed: 0.52 + this.rng() * 0.08, + } + this.positionCrossing(walker) + this.walkers.push(walker) + } + } + positionCrossing(w) { + const cross = (w.progress * 2 - 1) * ROAD.sidewalk + w.x = w.node.x + (w.axis === 'x' ? cross : w.edge * ROAD.crosswalk) + w.z = w.node.z + (w.axis === 'z' ? cross : w.edge * ROAD.crosswalk) + w.ry = + w.axis === 'x' + ? w.direction > 0 + ? Math.PI / 2 + : -Math.PI / 2 + : w.direction > 0 + ? 0 + : Math.PI + } + lamp(node, axis) { + const phase = signalState(this.time) + if (node.pedestrians > 0) return 'red' + if (node.owner !== null) { + const owner = this.cars[node.owner] + if (owner.reservation?.axis !== axis) return 'red' + } + return phase[axis] + } + canWalk(node) { + return signalState(this.time).walk && node.owner === null + } + step(dt) { + let left = Math.min(Math.max(0, dt), 0.1) + while (left > 1e-8) { + const h = Math.min(left, 1 / 40) + this.tick(h) + left -= h + } + } + tick(dt) { + this.time += dt + const phase = signalState(this.time) + for (const w of this.walkers) { + if (w.kind === 'stroll') { + w.s += w.speed * dt + Object.assign(w, loopPoint(w.route, w.s)) + continue + } + if (w.state === 'waiting') { + w.wait = Math.max(0, w.wait - dt) + const duration = (ROAD.sidewalk * 2) / w.speed + if ( + w.wait === 0 && + phase.walk && + phase.remaining > duration + 0.3 && + w.node.owner === null + ) { + w.state = 'crossing' + w.node.pedestrians++ + } + } + if (w.state === 'crossing') { + w.progress = clamp( + w.progress + (w.direction * w.speed * dt) / (2 * ROAD.sidewalk), + 0, + 1, + ) + if (w.progress === 0 || w.progress === 1) { + w.node.pedestrians-- + w.state = 'waiting' + w.direction *= -1 + w.wait = 8 + } + } + this.positionCrossing(w) + } + for (const car of this.cars) { + const loop = car.route + if (car.reservation && car.s >= car.reservation.exitAt - 1e-7) { + car.reservation.node.owner = null + car.reservation = null + car.nextGate = (car.nextGate + 1) % 4 + } + let gap = Infinity + for (const other of this.cars) + if (other !== car && other.route === loop) + gap = Math.min(gap, mod(other.s - car.s, loop.length)) + let allowed = Math.max(0, gap - ROAD.minGap) + if (!car.reservation) { + const gate = loop.gates[car.nextGate] + let distance = mod(gate.entry - mod(car.s, loop.length), loop.length) + if (loop.length - distance < 1e-7) distance = 0 + const permitted = + phase[gate.axis] === 'green' && + gate.node.owner === null && + gate.node.pedestrians === 0 && + gap > 2 * ROAD.gateHalf + ROAD.minGap + if (permitted && distance < 0.45) { + gate.node.owner = car.id + car.reservation = { + node: gate.node, + axis: gate.axis, + exitAt: car.s + distance + 2 * ROAD.gateHalf, + } + } else allowed = Math.min(allowed, distance) + } + const target = Math.min(car.maxSpeed, Math.sqrt(2 * 2.4 * allowed)) + car.speed = + target < car.speed + ? Math.max(target, car.speed - 2.4 * dt) + : Math.min(target, car.speed + 1.2 * dt) + const advance = Math.min(car.speed * dt, allowed) + car.s += advance + car.distance += advance + if (advance < car.speed * dt - 1e-8) car.speed = 0 + Object.assign(car, loopPoint(loop, car.s)) + } + } +} diff --git a/packages/react-charts/src/city/environment-controls.d.ts b/packages/react-charts/src/city/environment-controls.d.ts new file mode 100644 index 00000000..0dc76e51 --- /dev/null +++ b/packages/react-charts/src/city/environment-controls.d.ts @@ -0,0 +1 @@ +export { EnvironmentControls } from './types' diff --git a/packages/react-charts/src/city/environment-controls.jsx b/packages/react-charts/src/city/environment-controls.jsx new file mode 100644 index 00000000..6c691ceb --- /dev/null +++ b/packages/react-charts/src/city/environment-controls.jsx @@ -0,0 +1,66 @@ +import { useMapI18n } from './use-map-i18n.js' +export function EnvironmentControls({ + lighting, + weather, + onLightingChange, + onWeatherChange, + disabled, +}) { + const { t } = useMapI18n() + return ( + <> + {[ + { + id: 'lighting', + ariaLabel: t('lightingLabel'), + label: t('lighting'), + value: lighting, + onChange: onLightingChange, + options: [ + ['auto', t('auto')], + ['twilight', t('twilight')], + ['day', t('day')], + ['night', t('night')], + ], + }, + { + id: 'weather', + ariaLabel: t('weatherLabel'), + label: t('weather'), + value: weather, + onChange: onWeatherChange, + options: [ + ['auto', t('auto')], + ['none', t('none')], + ['rain', t('rain')], + ['snow', t('snow')], + ], + }, + ].map((control) => ( + + ))} + + ) +} diff --git a/packages/react-charts/src/city/fullscreen-wake-lock.test.mjs b/packages/react-charts/src/city/fullscreen-wake-lock.test.mjs new file mode 100644 index 00000000..19d986d0 --- /dev/null +++ b/packages/react-charts/src/city/fullscreen-wake-lock.test.mjs @@ -0,0 +1,133 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { maintainFullscreenWakeLock } from './engine/lib/fullscreen-wake-lock.js' + +const flush = () => new Promise((resolve) => setImmediate(resolve)) +function setup(request) { + const document = new EventTarget() + document.visibilityState = 'visible' + const element = {} + const locks = [] + const statuses = [] + let requests = 0 + const navigator = { + wakeLock: { + request: async (type) => { + requests++ + assert.equal(type, 'screen') + if (request) return request() + const lock = new EventTarget() + lock.released = false + lock.release = async () => { + lock.released = true + lock.dispatchEvent(new Event('release')) + } + locks.push(lock) + return lock + }, + }, + } + const dispose = maintainFullscreenWakeLock({ + document, + navigator, + element: () => element, + onStatus: (status) => statuses.push(status), + }) + return { + document, + navigator, + element, + locks, + statuses, + dispose, + get requests() { + return requests + }, + async fullscreen(value) { + document.fullscreenElement = value + document.dispatchEvent(new Event('fullscreenchange')) + await flush() + }, + async visible(value) { + document.visibilityState = value + document.dispatchEvent(new Event('visibilitychange')) + await flush() + }, + } +} + +test('wake lock belongs only to this fullscreen map, reacquires on visibility and releases on exit', async () => { + const state = setup() + assert.equal(state.requests, 0) + await state.fullscreen({}) + assert.equal(state.requests, 0) + await state.fullscreen(state.element) + assert.equal(state.requests, 1) + assert.equal(state.statuses.at(-1), 'active') + await state.visible('visible') + assert.equal(state.requests, 1) + await state.visible('hidden') + assert.equal(state.locks[0].released, true) + await state.visible('visible') + assert.equal(state.requests, 2) + await state.fullscreen(null) + assert.equal(state.locks[1].released, true) + assert.equal(state.statuses.at(-1), 'idle') + state.dispose() +}) + +test('unmount releases the lock and removes event listeners', async () => { + const state = setup() + await state.fullscreen(state.element) + state.dispose() + assert.equal(state.locks[0].released, true) + await state.visible('visible') + assert.equal(state.requests, 1) +}) + +test('late requests cannot keep the screen awake after fullscreen exit or unmount', async () => { + for (const unmount of [false, true]) { + let resolve + const state = setup( + () => + new Promise((done) => { + resolve = done + }), + ) + await state.fullscreen(state.element) + if (unmount) state.dispose() + else await state.fullscreen(null) + let released = false + resolve({ + release: async () => { + released = true + }, + }) + await flush() + assert.equal(released, true) + state.dispose() + } +}) + +test('unsupported or rejected wake locks report unavailable without breaking fullscreen', async () => { + for (const unsupported of [false, true]) { + const state = setup(() => Promise.reject(new Error('NotAllowedError'))) + if (unsupported) delete state.navigator.wakeLock + await state.fullscreen(state.element) + assert.equal(state.statuses.at(-1), 'unavailable') + assert.equal(state.document.fullscreenElement, state.element) + state.dispose() + } +}) + +test('system revocation reports unavailable and retries on the next visibility event', async () => { + const state = setup() + await state.fullscreen(state.element) + await state.locks[0].release() + assert.equal(state.statuses.at(-1), 'unavailable') + assert.equal(state.requests, 1) + await state.visible('visible') + assert.equal(state.requests, 2) + assert.equal(state.statuses.at(-1), 'active') + state.dispose() +}) diff --git a/packages/react-charts/src/city/i18n.js b/packages/react-charts/src/city/i18n.js new file mode 100644 index 00000000..fabd9bba --- /dev/null +++ b/packages/react-charts/src/city/i18n.js @@ -0,0 +1,246 @@ +// Interface text only: company names, categories and metric labels belong to the data. +export const mapMessages = { + 'pt-BR': { + title: 'Distrito das empresas', + mapLabel: 'Mapa interativo de empresas', + chartLabel: 'Health score das empresas em uma cidade 3D', + viewportLabel: + 'Cidade 3D. Arraste para girar, use a roda para zoom, clique em uma empresa para detalhes.', + chartDescription: + 'Cada construção representa uma empresa. Altura indica o valor da métrica, cor indica categorias e efeitos indicam o nível de risco.', + chartRole: 'gráfico 3D', + searchMap: 'Buscar empresa no mapa', + search: 'Buscar empresa', + searchPlaceholder: 'Buscar empresa…', + findPlaceholder: 'Encontrar uma empresa…', + empty: 'Nenhuma empresa encontrada.', + perspective: 'Perspectiva', + iso: 'Isométrica', + top: 'Vista superior', + details: 'Detalhes da empresa', + closeDetails: 'Fechar detalhes', + selectedCompany: 'EMPRESA SELECIONADA', + categories: 'Categorias', + focus: 'Focar empresa', + zoomCompany: 'Aproximar empresa', + cameraControls: 'Controles da câmera', + zoomIn: 'Aproximar', + zoomOut: 'Afastar', + resetView: 'Restaurar visão geral', + resetOrientation: 'Restaurar visão isométrica', + exitFullscreen: 'Sair da tela cheia', + enterFullscreen: 'Abrir mapa em tela cheia', + fullscreenUnavailable: 'Tela cheia indisponível neste navegador', + fullscreenError: 'Não foi possível alterar a tela cheia.', + fullscreenRetry: 'Não foi possível alterar a tela cheia. Tente novamente.', + hideControls: 'Ocultar controles', + showControls: 'Mostrar controles', + life: 'Cidade viva', + effects: 'Efeitos de risco', + autoRotate: 'Rotação automática', + localClock: 'Horário local', + monitoring: 'Acompanhamento da operação', + companySummary: 'Resumo das empresas filtradas', + companiesFound: 'Empresas encontradas', + allInCity: 'todas em uma cidade', + companiesPerCity: 'até {count} empresas por cidade', + recentCritical: 'Últimas entradas em alto risco', + monitorScope: 'Toda a base · até 4 empresas', + monitorLoading: 'Carregando empresas…', + monitorUnavailable: 'Não foi possível carregar as empresas.', + noCritical: 'Nenhuma empresa em alto risco.', + criticalWithoutDate: '{count} em alto risco sem data de entrada informada.', + updatedAt: 'Última atualização', + statusChangedAt: 'Saúde atual desde', + dateUnknown: 'Data não informada', + wakeLockUnavailable: + 'Não foi possível manter a tela ativa. Verifique as configurações de repouso do dispositivo.', + districtLabels: 'Categorias e interseções', + updating: 'Atualizando cidade…', + building: 'Construindo a cidade…', + buildingTitle: 'Construindo a cidade', + loadingCity: 'Carregando cidade {page}…', + preparing: 'Preparando empresas e bairros…', + loadError: 'Não foi possível carregar a aplicação.', + retry: 'Tentar novamente', + clearFilters: 'Limpar filtros', + allCompanies: 'Todas as empresas', + companyLimit: 'Até {count} empresas', + cityPage: 'cidade {page}', + companyRange: '{start}–{end} de {total} empresas', + instructions: + 'Arraste para girar · Scroll para zoom · Botão direito para mover', + lighting: 'Iluminação', + lightingLabel: 'Iluminação da cidade', + weather: 'Clima', + weatherLabel: 'Clima da cidade', + auto: 'Automático', + twilight: 'Nascente/poente', + day: 'Dia', + night: 'Noite', + none: 'Sem precipitação', + rain: 'Chuva', + snow: 'Neve', + 'simulation.none': 'Simulação: sem precipitação', + 'simulation.rain': 'Simulação: chuva', + 'simulation.snow': 'Simulação: neve', + 'status.healthy': 'Saudável', + 'status.warning': 'Risco médio', + 'status.critical': 'Alto risco', + 'description.healthy': + 'Operação saudável. A construção mantém sua aparência normal.', + 'description.warning': + 'Ponto de atenção: fumaça clara e alerta amarelo sinalizam risco médio.', + 'description.critical': + 'Alto risco: construção piscando em vermelho, chamas nas janelas e fumaça escura indicam prioridade de atenção.', + weatherPaused: 'Clima pausado', + 'weather.rain': 'Chuva na região', + 'weather.snow': 'Neve na região', + 'weather.none': 'Sem chuva ou neve na região', + locationDenied: 'Localização não autorizada · clima indisponível', + weatherUnavailable: 'Clima indisponível · nova tentativa em 15 min', + locationPrompt: 'Permita a localização para mostrar chuva ou neve', + locationUnavailable: 'Localização indisponível neste navegador', + locationError: 'Não foi possível obter a localização', + weatherLoading: 'Consultando o clima da região…', + contextLost: + 'A conexão gráfica foi interrompida. Recarregue a página para restaurar o mapa.', + webglError: + 'Não foi possível abrir a cidade 3D. Use um navegador com WebGL habilitado e aceleração gráfica disponível.', + }, + 'en-US': { + title: 'Business district', + mapLabel: 'Interactive company map', + chartLabel: 'Company health scores in a 3D city', + viewportLabel: + '3D city. Drag to rotate, scroll to zoom, click a company for details.', + chartDescription: + 'Each building represents a company. Height indicates the metric value, color indicates categories, and effects indicate the risk level.', + chartRole: '3D chart', + searchMap: 'Search companies on the map', + search: 'Search companies', + searchPlaceholder: 'Search companies…', + findPlaceholder: 'Find a company…', + empty: 'No companies found.', + perspective: 'Perspective', + iso: 'Isometric', + top: 'Top view', + details: 'Company details', + closeDetails: 'Close details', + selectedCompany: 'SELECTED COMPANY', + categories: 'Categories', + focus: 'Focus company', + zoomCompany: 'Zoom to company', + cameraControls: 'Camera controls', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + resetView: 'Reset view', + resetOrientation: 'Restore isometric view', + exitFullscreen: 'Exit fullscreen', + enterFullscreen: 'Open map in fullscreen', + fullscreenUnavailable: 'Fullscreen is unavailable in this browser', + fullscreenError: 'Could not change fullscreen mode.', + fullscreenRetry: 'Could not change fullscreen mode. Try again.', + hideControls: 'Hide controls', + showControls: 'Show controls', + life: 'City life', + effects: 'Risk effects', + autoRotate: 'Auto rotation', + localClock: 'Local time', + monitoring: 'Operations monitoring', + companySummary: 'Company summary', + companiesFound: 'Companies found', + allInCity: 'all in one city', + companiesPerCity: 'up to {count} companies per city', + recentCritical: 'Latest high-risk entries', + monitorScope: 'All companies · up to 4 entries', + monitorLoading: 'Loading companies…', + monitorUnavailable: 'Could not load companies.', + noCritical: 'No high-risk companies.', + criticalWithoutDate: '{count} high-risk companies have no entry date.', + updatedAt: 'Last updated', + statusChangedAt: 'Current health since', + dateUnknown: 'Date not provided', + wakeLockUnavailable: + "Could not keep the screen awake. Check your device's sleep settings.", + districtLabels: 'Categories and intersections', + updating: 'Updating city…', + building: 'Building the city…', + buildingTitle: 'Building the city', + loadingCity: 'Loading city {page}…', + preparing: 'Preparing companies and districts…', + loadError: 'Could not load the application.', + retry: 'Try again', + clearFilters: 'Clear filters', + allCompanies: 'All companies', + companyLimit: 'Up to {count} companies', + cityPage: 'city {page}', + companyRange: '{start}–{end} of {total} companies', + instructions: 'Drag to rotate · Scroll to zoom · Right mouse button to pan', + lighting: 'Lighting', + lightingLabel: 'City lighting', + weather: 'Weather', + weatherLabel: 'City weather', + auto: 'Automatic', + twilight: 'Sunrise/sunset', + day: 'Day', + night: 'Night', + none: 'No precipitation', + rain: 'Rain', + snow: 'Snow', + 'simulation.none': 'Simulation: no precipitation', + 'simulation.rain': 'Simulation: rain', + 'simulation.snow': 'Simulation: snow', + 'status.healthy': 'Healthy', + 'status.warning': 'Medium risk', + 'status.critical': 'High risk', + 'description.healthy': + 'Healthy operation. The building retains its normal appearance.', + 'description.warning': + 'Attention needed: light smoke and a yellow warning indicate medium risk.', + 'description.critical': + 'High risk: a building flashing red, flames in the windows, and dark smoke indicate a need for urgent attention.', + weatherPaused: 'Weather paused', + 'weather.rain': 'Rain in the area', + 'weather.snow': 'Snow in the area', + 'weather.none': 'No rain or snow in the area', + locationDenied: 'Location permission denied · weather unavailable', + weatherUnavailable: 'Weather unavailable · retrying in 15 min', + locationPrompt: 'Allow location access to show rain or snow', + locationUnavailable: 'Location is unavailable in this browser', + locationError: 'Could not determine your location', + weatherLoading: 'Checking local weather…', + contextLost: + 'The graphics connection was lost. Reload the page to restore the map.', + webglError: + 'Could not open the 3D city. Use a browser with WebGL enabled and graphics acceleration available.', + }, +} + +export function resolveMapLocale(language) { + return typeof language === 'string' && language.toLowerCase() === 'pt-br' + ? 'pt-BR' + : 'en-US' +} + +export function getBrowserMapLocale() { + return resolveMapLocale( + typeof window === 'undefined' ? undefined : window.navigator.language, + ) +} + +export function getMapI18n(language) { + const locale = resolveMapLocale(language) + const messages = mapMessages[locale] + const formatter = new Intl.NumberFormat(locale) + return { + locale, + format: (value) => formatter.format(value), + t: (key, values = {}) => + (messages[key] ?? mapMessages['en-US'][key] ?? key).replace( + /\{(\w+)\}/g, + (match, name) => + Object.hasOwn(values, name) ? String(values[name]) : match, + ), + } +} diff --git a/packages/react-charts/src/city/index.d.ts b/packages/react-charts/src/city/index.d.ts new file mode 100644 index 00000000..379836ab --- /dev/null +++ b/packages/react-charts/src/city/index.d.ts @@ -0,0 +1,6 @@ +export { + defineCityChart, + cityMark, + cityFocus, + createCityRenderer, +} from './types' diff --git a/packages/react-charts/src/city/index.js b/packages/react-charts/src/city/index.js new file mode 100644 index 00000000..69e1ca10 --- /dev/null +++ b/packages/react-charts/src/city/index.js @@ -0,0 +1,4 @@ +export { cityMark } from './mark.js' +export { defineCityChart } from './definition.js' +export { createCityRenderer } from './renderer.js' +export { cityFocus } from './interaction.js' diff --git a/packages/react-charts/src/city/interaction.js b/packages/react-charts/src/city/interaction.js new file mode 100644 index 00000000..5e3a1089 --- /dev/null +++ b/packages/react-charts/src/city/interaction.js @@ -0,0 +1,19 @@ +// A definition can be mounted in several charts. Associate each compiled point +// with its own mounted surface, never with a global camera or a datum ID. +const surfaces = new WeakMap() + +export function bindCityPoints(points, resolve) { + for (const point of points) surfaces.set(point, resolve) +} + +export const cityFocus = { + resolve(points, { x, y }) { + return surfaces.get(points[0])?.(x, y) ?? [] + }, + group(_points, { point }) { + return [point] + }, + navigation(points) { + return points + }, +} diff --git a/packages/react-charts/src/city/landscape.test.mjs b/packages/react-charts/src/city/landscape.test.mjs new file mode 100644 index 00000000..193f7c07 --- /dev/null +++ b/packages/react-charts/src/city/landscape.test.mjs @@ -0,0 +1,96 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { greenLots } from './engine/landscape.js' +import { + demoCompanies, + layoutCity, + paginateCompanies, + PAGE_SIZES, + ROAD_EVERY, +} from './engine/model.js' + +test('every area without a company receives a garden without occupying streets for every filter and page', () => { + const records = demoCompanies() + for (const size of PAGE_SIZES) + for (const filter of [ + {}, + { region: '7' }, + { status: 'warning' }, + { query: 'sem-resultados-xyz' }, + ]) { + const first = paginateCompanies(records, filter, 1, size) + for (const page of new Set([1, first.pages])) { + const layout = layoutCity( + paginateCompanies(records, filter, page, size).companies, + ) + const lots = greenLots(layout), + green = new Set(lots.map((p) => `${p.col},${p.row}`)) + const occupied = new Set( + layout.companies.map((c) => `${c.col},${c.row}`), + ) + assert.equal(green.size, lots.length) + const b = layout.roadBounds + for (let row = b.z1; row <= b.z2; row++) + for (let col = b.x1; col <= b.x2; col++) { + const key = `${col},${row}`, + road = col % ROAD_EVERY === 0 || row % ROAD_EVERY === 0 + assert.equal(green.has(key), !road && !occupied.has(key)) + } + for (const lot of lots) { + assert.ok(lot.trees.length <= 3) + for (const tree of lot.trees) { + // Even the full canopy stays inside its lot and off sidewalks. + assert.ok(Math.abs(tree.dx) + tree.scale < (2.8 - 0.12) / 2) + assert.ok(Math.abs(tree.dz) + tree.scale < (2.8 - 0.12) / 2) + for (const bench of lot.benches) + assert.ok( + Math.hypot(tree.dx - bench.dx, tree.dz - bench.dz) >= + tree.scale + 0.55, + ) + } + for (const bench of lot.benches) { + // The entire rotated bench fits inside the lawn lot. + const cos = Math.abs(Math.cos(bench.rotation)), + sin = Math.abs(Math.sin(bench.rotation)) + assert.ok(Math.abs(bench.dx) + 0.24 * cos + 0.08 * sin < 1.34) + assert.ok(Math.abs(bench.dz) + 0.24 * sin + 0.08 * cos < 1.34) + } + } + } + } +}) + +test('gardens vary between clearings and tree clusters with a few stable benches', () => { + const layout = layoutCity(demoCompanies().slice(0, 100)) + const lots = greenLots(layout) + assert.deepEqual(greenLots(layout), lots) + assert.deepEqual( + [...new Set(lots.map((lot) => lot.trees.length))].sort(), + [0, 1, 2, 3], + ) + const benches = lots.filter((lot) => lot.benches.length > 0) + assert.ok(benches.length > 0 && benches.length < lots.length * 0.15) + assert.ok(new Set(benches.map((lot) => lot.benches[0].rotation)).size > 1) + for (const lot of lots) + for (let i = 0; i < lot.trees.length; i++) + for (const other of lot.trees.slice(i + 1)) { + const tree = lot.trees[i] + assert.ok( + Math.hypot(tree.dx - other.dx, tree.dz - other.dz) >= + tree.scale + other.scale + 0.08, + ) + } +}) + +test('a freed lot becomes a garden and can receive a company again without overlapping vegetation', () => { + const layout = layoutCity(demoCompanies().slice(0, 100)), + removed = layout.companies[0] + const before = greenLots(layout) + const vacant = greenLots({ ...layout, companies: layout.companies.slice(1) }) + assert.ok(vacant.some((p) => p.col === removed.col && p.row === removed.row)) + assert.deepEqual( + vacant.filter((p) => p.col !== removed.col || p.row !== removed.row), + before, + ) + assert.deepEqual(greenLots(layout), before) +}) diff --git a/packages/react-charts/src/city/layout.js b/packages/react-charts/src/city/layout.js new file mode 100644 index 00000000..9d1e0653 --- /dev/null +++ b/packages/react-charts/src/city/layout.js @@ -0,0 +1,13 @@ +// Renderer metadata lives on the mark's scene group, never on user data. +// A symbol survives scene-node copies without becoming an SVG/DOM attribute. +export const cityLayout = Symbol('cityLayout') + +export function readCityLayout(nodes) { + for (const node of nodes) { + if (node[cityLayout]) return node[cityLayout] + if (node.children) { + const layout = readCityLayout(node.children) + if (layout) return layout + } + } +} diff --git a/packages/react-charts/src/city/map-compass.test.mjs b/packages/react-charts/src/city/map-compass.test.mjs new file mode 100644 index 00000000..42077209 --- /dev/null +++ b/packages/react-charts/src/city/map-compass.test.mjs @@ -0,0 +1,42 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import * as THREE from 'three' +import { mapNorthAngle } from './engine/lib/map-compass.js' + +test('north follows the projected ground direction at every camera heading and elevation', () => { + const camera = new THREE.OrthographicCamera(-160, 160, 100, -100, 0.1, 800) + for (const polar of [1e-6, Math.acos(1 / Math.sqrt(3))]) { + for (const azimuth of [ + 0, + Math.PI / 4, + Math.PI / 2, + Math.PI, + -Math.PI / 2, + ]) { + for (const zoom of [0.5, 1, 3]) { + const target = new THREE.Vector3(20, 5, -10) + camera.position + .copy(target) + .add(new THREE.Vector3().setFromSphericalCoords(200, polar, azimuth)) + camera.lookAt(target) + camera.zoom = zoom + camera.updateProjectionMatrix() + camera.updateMatrixWorld() + const origin = target.clone().project(camera) + const north = target + .clone() + .add(new THREE.Vector3(0, 0, -1)) + .project(camera) + // Convert NDC to pixels for a viewport matching the camera's aspect. + const expected = Math.atan2( + (north.x - origin.x) * 160, + (north.y - origin.y) * 100, + ) + const actual = mapNorthAngle(camera) + assert.ok(Math.abs(Math.sin(actual) - Math.sin(expected)) < 1e-9) + assert.ok(Math.abs(Math.cos(actual) - Math.cos(expected)) < 1e-9) + if (azimuth === 0) assert.ok(Math.abs(actual) < 1e-9) + } + } + } +}) diff --git a/packages/react-charts/src/city/map-tooltip.test.mjs b/packages/react-charts/src/city/map-tooltip.test.mjs new file mode 100644 index 00000000..fc361a3e --- /dev/null +++ b/packages/react-charts/src/city/map-tooltip.test.mjs @@ -0,0 +1,30 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { mapTooltipAnchor } from './engine/lib/map-tooltip.js' + +test('the anchor keeps the roof projection inside the map', () => { + assert.deepEqual(mapTooltipAnchor({ x: 0.5, y: -0.5 }, 800, 600), { + x: 600, + y: 450, + }) +}) + +test('roofs outside the view retain a visible anchor at the edges', () => { + for (const point of [ + { x: 0, y: 2 }, + { x: 0, y: -2 }, + { x: -2, y: 0 }, + { x: 2, y: 0 }, + { x: 4, y: 4, z: 2 }, + ]) { + const anchor = mapTooltipAnchor(point, 800, 600) + assert.ok(anchor.x >= 18 && anchor.x <= 782) + assert.ok(anchor.y >= 18 && anchor.y <= 582) + } +}) + +test('transient dimensions do not produce invalid anchors', () => { + assert.equal(mapTooltipAnchor({ x: 0, y: 0 }, 0, 600), null) + assert.equal(mapTooltipAnchor({ x: NaN, y: 0 }, 800, 600), null) + assert.deepEqual(mapTooltipAnchor({ x: 2, y: 2 }, 20, 10), { x: 10, y: 5 }) +}) diff --git a/packages/react-charts/src/city/map.d.ts b/packages/react-charts/src/city/map.d.ts new file mode 100644 index 00000000..2afce27c --- /dev/null +++ b/packages/react-charts/src/city/map.d.ts @@ -0,0 +1 @@ +export { CityMap } from './types' diff --git a/packages/react-charts/src/city/map.jsx b/packages/react-charts/src/city/map.jsx new file mode 100644 index 00000000..06a0c34b --- /dev/null +++ b/packages/react-charts/src/city/map.jsx @@ -0,0 +1,502 @@ +import { useMapI18n } from './use-map-i18n.js' +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { defineCityChart } from './definition.js' +import { CityChart } from './react.jsx' +import { CompanyDate, CompanyMonitor } from './company-monitor.jsx' +import { useFullscreenWakeLock } from './use-fullscreen-wake-lock.js' +import { categoryLabel, metricFor } from './engine/model.js' + +function useSetting(value, initial, onChange) { + const [local, setLocal] = useState(initial) + return [ + value ?? local, + (next) => { + setLocal(next) + onChange?.(next) + }, + ] +} + +/** A complete, self-contained city with camera, weather and scene controls. */ +export const CityMap = forwardRef(function CityMap( + { + data, + onSelect, + onFocusChange, + view: viewProp, + onViewChange, + life: lifeProp, + onLifeChange, + effects: effectsProp, + onEffectsChange, + autoRotate: autoRotateProp, + onAutoRotateChange, + showDistrictLabels: labelsProp, + onDistrictLabelsChange, + weather = 'none', + lightingMode = 'auto', + showControls = true, + showEnvironmentControls = false, + showSearch = true, + showDetails = true, + showMonitor = true, + showCompass = true, + showInstructions = true, + busy = false, + title: titleProp, + ariaLabel: ariaLabelProp, + className = '', + theme = 'default', + children, + }, + ref, +) { + const { t, format, locale } = useMapI18n() + const title = titleProp ?? t('title') + const ariaLabel = ariaLabelProp ?? t('mapLabel') + const panelRef = useRef(null) + const chartRef = useRef(null) + const [view, setView] = useSetting(viewProp, 'iso', onViewChange) + const [life, setLife] = useSetting(lifeProp, true, onLifeChange) + const [effects, setEffects] = useSetting(effectsProp, true, onEffectsChange) + const [autoRotate, setAutoRotate] = useSetting( + autoRotateProp, + false, + onAutoRotateChange, + ) + const [labels, setLabels] = useSetting( + labelsProp, + false, + onDistrictLabelsChange, + ) + const [selectedId, setSelectedId] = useState(null) + const [query, setQuery] = useState('') + const [controlsVisible, setControlsVisible] = useState(true) + const [fullscreen, setFullscreen] = useState(false) + const [fullscreenAvailable, setFullscreenAvailable] = useState(false) + const [fullscreenError, setFullscreenError] = useState(null) + const wakeLockStatus = useFullscreenWakeLock(panelRef) + const definition = useMemo(() => defineCityChart(data), [data]) + const selected = data.find((datum) => datum.id === selectedId) ?? null + const matches = query.trim() + ? data + .filter((datum) => + datum.name + .toLocaleLowerCase() + .includes(query.trim().toLocaleLowerCase()), + ) + .slice(0, 8) + : [] + const choose = (datum, focus = false, maxZoom = false) => { + setSelectedId(datum?.id ?? null) + onSelect?.(datum) + if (datum && focus) + chartRef.current?.select(datum, { focus: true, maxZoom }) + } + const changeView = (next) => { + setView(next) + chartRef.current?.view(next) + } + + useImperativeHandle(ref, () => ({ + zoom: (factor) => chartRef.current?.zoom(factor), + view: changeView, + select: (datum, options = {}) => + choose(datum, options.focus, options.maxZoom), + deselect: () => choose(null), + cancel: () => chartRef.current?.cancel(), + capture: () => chartRef.current?.capture(), + play: (direction) => chartRef.current?.play(direction), + scrollIntoView: () => chartRef.current?.scrollIntoView(), + stats: () => chartRef.current?.stats(), + })) + + useEffect(() => { + const update = () => + setFullscreen(document.fullscreenElement === panelRef.current) + setFullscreenAvailable( + Boolean( + document.fullscreenEnabled && panelRef.current?.requestFullscreen, + ), + ) + document.addEventListener('fullscreenchange', update) + return () => document.removeEventListener('fullscreenchange', update) + }, []) + + const toggleFullscreen = async () => { + setFullscreenError(null) + try { + chartRef.current?.cancel() + if (document.fullscreenElement === panelRef.current) + await document.exitFullscreen() + else await panelRef.current.requestFullscreen() + } catch { + setFullscreenError('fullscreenError') + } + } + + return ( +
{ + if ( + event.target.closest( + "input, textarea, select, button, [contenteditable='true']", + ) + ) + return + if (event.key === 'Escape') choose(null) + if (busy || !['+', '=', '-', '0'].includes(event.key)) return + event.preventDefault() + if (event.key === '0') changeView('iso') + else chartRef.current?.zoom(event.key === '-' ? 1 / 1.3 : 1.3) + }} + > + choose(point?.datum ?? null)} + onFocusChange={onFocusChange} + view={view} + life={life} + effects={effects} + autoRotate={autoRotate} + weather={weather} + lightingMode={lightingMode} + showDistrictLabels={labels} + showEnvironmentControls={showControls && showEnvironmentControls} + selected={selected} + busy={busy} + ariaLabel={ariaLabel} + > + {({ ready, error, environmentControls }) => { + const disabled = busy || !ready || Boolean(error) + return ( + <> +
+ +
+ {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..e911e719 --- /dev/null +++ b/packages/react-charts/src/city/mark.js @@ -0,0 +1,65 @@ +import { createMark } from '@tanstack/charts' +import { categoryLabel, metricFor, layoutCity } from './engine/model.js' +import { validateCityData } from './data.js' +import { cityLayout } from './layout.js' + +// A city is a spatial mark: x/z locate each company and a metric encodes height. +// The semantic scene also remains usable by TanStack's SVG renderer (top view). +/** + * @template {import('./data.js').CityDatum} TDatum + * @param {readonly TDatum[]} data Original application rows, never mutated. + * @param {{ id?: string, layout?: ReturnType }} [options] + * @returns {import('@tanstack/charts').ChartMark} + */ +export function cityMark(data, { id = 'companies', layout } = {}) { + const rows = validateCityData(data) + layout ??= layoutCity(rows) + return createMark(() => ({ + id, + channels: { + x: { scale: 'x', values: layout.companies.map((c) => c.x) }, + y: { scale: 'y', values: layout.companies.map((c) => c.z) }, + color: { scale: 'color', values: layout.companies.map((c) => c.mask) }, + height: { values: layout.companies.map((c) => metricFor(c).value) }, + health: { values: layout.companies.map((c) => c.status) }, + }, + render({ scales, color }) { + const points = layout.companies.map((company, datumIndex) => ({ + key: `${id}:${company.id}`, + markId: id, + group: company.mask, + groupLabel: categoryLabel(company), + datum: data[datumIndex], + datumIndex, + xValue: company.x, + yValue: company.z, + x: scales.x.map(company.x), + y: scales.y.map(company.z), + color: color(company.mask), + })) + return { + points, + nodes: [ + { + kind: 'group', + className: 'ts-chart__rect', + ariaHidden: true, + key: `${id}:layout`, + [cityLayout]: layout, + children: points.map((point) => ({ + kind: 'rect', + key: point.key, + x: point.x - 3, + y: point.y - 3, + width: 6, + height: 6, + radius: 1, + style: { fill: point.color }, + interaction: { point, affinity: 'geometry' }, + })), + }, + ], + } + }, + })) +} diff --git a/packages/react-charts/src/city/metric-scale.test.mjs b/packages/react-charts/src/city/metric-scale.test.mjs new file mode 100644 index 00000000..92e7ce1d --- /dev/null +++ b/packages/react-charts/src/city/metric-scale.test.mjs @@ -0,0 +1,109 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import * as THREE from 'three' +import { metricDomainFor, sizeForValue, layoutCity } from './engine/model.js' +import { facadeWindows } from './engine/plumes.js' +import { City } from './engine/city.js' +import { disposeSceneGroups } from './engine/scene-resources.js' + +const records = (values) => + values.map((value, i) => ({ + id: String(i), + name: `Company ${i}`, + mask: 1, + status: 'healthy', + metric: { label: 'Revenue', value }, + })) + +test('adaptive heights distinguish values inside former bands at any magnitude', () => { + for (const values of [ + [0, 0.25, 0.5], + [2000, 3000, 4000], + [2e9, 3e9, 4e9], + ]) { + const domain = metricDomainFor(records(values)) + const sizes = values.map((value) => sizeForValue(value, domain)) + assert.equal(sizes[0].height, 0.95) + assert.equal(sizes[2].height, 16.8) + assert.equal(sizes[2].floors, 14) + assert.equal(sizes[0].floors, 1) + assert.equal(sizes[1].height, 0.95 + Math.sqrt(0.5) * 15.85) + assert.ok(sizes[1].height > (0.95 + 16.8) / 2) + assert.ok(sizes[0].height < sizes[1].height) + assert.ok(sizes[1].height < sizes[2].height) + } +}) + +test('empty, single, equal, zero, duplicate and legacy metrics have defined heights', () => { + assert.deepEqual(metricDomainFor([]), [0, 0]) + assert.deepEqual(metricDomainFor([{ users: 2 }, { users: 9 }]), [2, 9]) + for (const values of [[0], [25], [0, 0], [100, 100]]) { + const domain = metricDomainFor(records(values)) + for (const value of values) + assert.equal(sizeForValue(value, domain).height, 8.875) + } + const domain = metricDomainFor(records([1, 2, 2, 3])) + assert.deepEqual(domain, [1, 3]) + assert.equal(sizeForValue(2, domain).height, 0.95 + Math.sqrt(0.5) * 15.85) + assert.equal(sizeForValue(-1, domain).height, 0.95) + assert.equal(sizeForValue(10, domain).height, 16.8) +}) + +test('square-root scaling gives intermediate values more presence and fits every facade', () => { + const domain = [0, 100] + assert.equal(sizeForValue(25, domain).height, 8.875) + let previous = 0 + for (let value = 0; value <= 100; value += 0.25) { + const size = sizeForValue(value, domain) + assert.ok(size.height > previous) + assert.ok(size.floors >= 1 && size.floors <= 14) + previous = size.height + const company = { x: 0, z: 0, height: size.height } + const windows = facadeWindows(company, size, 1.5, 1.5) + assert.equal(windows.length, size.floors * 4) + for (const window of windows) { + assert.ok(window.y - window.height / 2 > 0) + assert.ok(window.y + window.height / 2 < size.height + 0.13) + } + } +}) + +test('rendered geometry rescales unchanged companies when the domain changes', () => { + const city = Object.assign(Object.create(City.prototype), { + scene: new THREE.Scene(), + terrain: new THREE.Group(), + buildings: new THREE.Group(), + palettes: new Map([[1, new THREE.Color('#4b91ef')]]), + renderer: { renderLists: { dispose() {} } }, + selection: { visible: false }, + resize() {}, + buildTerrain() {}, + buildTraffic() {}, + buildEffects() {}, + invalidate() {}, + select() {}, + }) + try { + let layout = layoutCity(records([2000, 3000, 4000])) + city.setCompanies(layout.companies, layout) + const original = city.companies[1].height + assert.equal(city.body.userData.items[1].h, original) + layout = layoutCity(records([2000, 3000, 6000])) + city.updateCompanies(layout, new Map()) + assert.ok(city.companies[1].height < original) + assert.equal(city.body.userData.items[1].h, city.companies[1].height) + // A full-data domain can change even when all companies on this page stay the same. + layout = { + ...layoutCity(records([2000, 3000, 6000])), + metricDomain: [0, 10000], + } + city.updateCompanies(layout, new Map()) + assert.equal( + city.companies[1].height, + sizeForValue(3000, [0, 10000]).height, + ) + assert.equal(city.body.userData.items[1].h, city.companies[1].height) + } finally { + disposeSceneGroups([city.scene, city.buildings]) + } +}) diff --git a/packages/react-charts/src/city/plume-renderer.test.mjs b/packages/react-charts/src/city/plume-renderer.test.mjs new file mode 100644 index 00000000..dd0b8584 --- /dev/null +++ b/packages/react-charts/src/city/plume-renderer.test.mjs @@ -0,0 +1,38 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import * as THREE from 'three' +import { WindowPlumes } from './engine/plume-renderer.js' +import { facadeWindows, windowEmitters } from './engine/plumes.js' +import { sizeForUsers } from './engine/model.js' + +test('Three.js volumes remain valid, respect selection and can be hidden', () => { + const c = { + id: 'company', + x: 3, + z: 6, + height: 4.3, + house: false, + status: 'critical', + }, + size = sizeForUsers(1000) + const emitters = windowEmitters(c, facadeWindows(c, size, 1.5, 1.4)), + parent = new THREE.Group(), + plumes = new WindowPlumes(emitters, parent) + for (const time of [0, 0.5, 2, 1000]) { + plumes.update(time, true, new Set([c.id])) + for (const { mesh } of plumes.layers) { + assert.ok(mesh.isInstancedMesh) + assert.ok(mesh.count > 0) + assert.ok([...mesh.instanceMatrix.array].every(Number.isFinite)) + assert.ok([...mesh.geometry.attributes.aAlpha.array].some((a) => a > 0)) + } + } + plumes.update(1, true, new Set()) + for (const { mesh } of plumes.layers) + assert.ok([...mesh.geometry.attributes.aAlpha.array].every((a) => a === 0)) + plumes.update(1, false, new Set([c.id])) + for (const { mesh } of plumes.layers) assert.equal(mesh.visible, false) + const empty = new WindowPlumes([], new THREE.Group()) + empty.update(1, true, new Set()) + for (const { mesh } of empty.layers) assert.equal(mesh.count, 0) +}) diff --git a/packages/react-charts/src/city/plumes.test.mjs b/packages/react-charts/src/city/plumes.test.mjs new file mode 100644 index 00000000..9309965f --- /dev/null +++ b/packages/react-charts/src/city/plumes.test.mjs @@ -0,0 +1,96 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { + facadeWindows, + windowEmitters, + samplePlume, + makeParticles, +} from './engine/plumes.js' +import { sizeForUsers } from './engine/model.js' + +function sources(status = 'critical') { + const c = { id: 'test', x: 10, z: 20, users: 2200, status } + const size = sizeForUsers(c.users) + c.height = size.height + c.house = size.house + return { + windows: facadeWindows(c, size, 1.6, 1.5), + emitters: windowEmitters(c, facadeWindows(c, size, 1.6, 1.5)), + } +} +test('each emission belongs to a real window and originates inside the building', () => { + const { windows, emitters } = sources() + assert.ok(emitters.length > 0) + for (const e of emitters) { + assert.ok(windows.some((w) => w.x === e.x && w.y === e.y && w.z === e.z)) + for (let seed = 0; seed < 100; seed++) { + const p = samplePlume(e, 0, seed) + const depth = (p.x - e.x) * e.nx + (p.z - e.z) * e.nz + assert.ok(depth < 0) + assert.ok(Math.abs(p.y - e.y) + p.radius < e.height / 2) + const side = -(p.x - e.x) * e.nz + (p.z - e.z) * e.nx + assert.ok(Math.abs(side) + p.radius < e.width / 2) + } + } +}) +test('flow fits the opening then expands outward along the normal and rises through buoyancy', () => { + for (const { emitters } of [sources(), sources('warning')]) + for (const e of emitters) + for (const kind of ['flame', 'smoke']) { + let previousY = -Infinity + for (let age = 0; age < 1.2; age += 0.01) { + const p = samplePlume(e, age, 3, kind) + assert.ok(p.y >= previousY - 1e-8) + previousY = p.y + if (p.outward < 0.035) { + const side = -(p.x - e.x) * e.nz + (p.z - e.z) * e.nx + assert.ok(Math.abs(side) + p.radius < e.width / 2) + assert.ok(Math.abs(p.y - e.y) + p.length < e.height / 2) + } + } + const birth = samplePlume(e, 0, 3, kind), + grown = samplePlume(e, 1, 3, kind) + assert.ok(grown.outward > 0) + assert.ok(grown.y > birth.y) + assert.ok(grown.radius > birth.radius) + } +}) +test('medium risk produces only smoke; healthy buildings have no emitter', () => { + const warning = sources('warning') + assert.equal(makeParticles(warning.emitters, 'flame').length, 0) + assert.ok(makeParticles(warning.emitters, 'smoke').length > 0) + assert.equal(sources('healthy').emitters.length, 0) + const e = sources().emitters[0] + const narrow = { ...e, width: e.width / 2, rate: e.rate / 2 } + assert.ok( + makeParticles([narrow], 'flame').length < + makeParticles([e], 'flame').length, + ) +}) + +test('larger buildings spread smoke across more windows and floors without introducing fire', () => { + let previous = 0 + for (const users of [30, 300, 1000, 3000]) { + const size = sizeForUsers(users), + c = { + id: `warning-${users}`, + x: 0, + z: 0, + users, + status: 'warning', + height: size.height, + house: size.house, + } + const windows = facadeWindows(c, size, 1.5, 1.4), + emitters = windowEmitters(c, windows) + assert.ok(emitters.length > previous) + previous = emitters.length + assert.equal(new Set(emitters.map((e) => e.index)).size, emitters.length) + assert.ok(emitters.every((e) => windows.some((w) => w.index === e.index))) + if (!size.house) { + assert.ok(new Set(emitters.map((e) => e.y)).size > 1) + assert.ok(new Set(emitters.map((e) => e.nx)).size > 1) + } + assert.equal(makeParticles(emitters, 'flame').length, 0) + } +}) diff --git a/packages/react-charts/src/city/react.d.ts b/packages/react-charts/src/city/react.d.ts new file mode 100644 index 00000000..58a81e14 --- /dev/null +++ b/packages/react-charts/src/city/react.d.ts @@ -0,0 +1 @@ +export { CityChart } from './types' diff --git a/packages/react-charts/src/city/react.jsx b/packages/react-charts/src/city/react.jsx new file mode 100644 index 00000000..0b79de3a --- /dev/null +++ b/packages/react-charts/src/city/react.jsx @@ -0,0 +1,258 @@ +import { cityStyles } from './styles' +import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect.js' +import { useMapI18n } from './use-map-i18n.js' +import { useId, useRef, useState } from 'react' +import { Chart } from '../core' +import { regionsFor, categoryLabel, metricFor } from './engine/model.js' +import { useCityChart } from './use-city-chart.js' +import { EnvironmentControls } from './environment-controls.jsx' + +function DistrictLabels({ layout, labelsRef, labelNodes, visible }) { + const { format } = useMapI18n() + return ( + + ) +} +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 ( + <> + +