mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
train 3d visualizations
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Maximize2, Minimize2, Train, TrainFront, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
@@ -32,10 +32,10 @@ const wagonKind = (w: Wagon): WagonKind => {
|
||||
};
|
||||
|
||||
const WAGON_BODY_COLOR: Record<WagonKind, number> = {
|
||||
container: 0x4a5568,
|
||||
bulk: 0x7b4a2d,
|
||||
tank: 0x8a8f98,
|
||||
flat: 0x3d4852,
|
||||
container: 0x5f7d9c,
|
||||
bulk: 0xa8683f,
|
||||
tank: 0xb8bec8,
|
||||
flat: 0x9aa4ae,
|
||||
};
|
||||
|
||||
const M = 1; // 1 unit = 1 meter
|
||||
@@ -43,22 +43,148 @@ const GAUGE = 1.435 * M;
|
||||
const WHEEL_R = 0.46 * M;
|
||||
const DECK_H = 1.2 * M;
|
||||
|
||||
function buildStars(scene: THREE.Scene) {
|
||||
const geo = new THREE.BufferGeometry();
|
||||
const n = 2500;
|
||||
const pos = new Float32Array(n * 3);
|
||||
for (let i = 0; i < n; i++) {
|
||||
// random point on upper hemisphere, far away
|
||||
const r = 900;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(Math.random() * 0.95); // bias above horizon
|
||||
pos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
pos[i * 3 + 1] = Math.abs(r * Math.cos(phi)) + 20;
|
||||
pos[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta);
|
||||
// deterministic pseudo-random so scenery doesn't jump between rebuilds
|
||||
const makeRng = (seed: number) => () => {
|
||||
seed = (seed * 1664525 + 1013904223) % 4294967296;
|
||||
return seed / 4294967296;
|
||||
};
|
||||
|
||||
function makeLabelSprite(text: string, bg: string): THREE.Sprite {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 512;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.fillStyle = bg;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(6, 6, 500, 116, 28);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.9)";
|
||||
ctx.lineWidth = 6;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 58px system-ui, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, 256, 68);
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, depthTest: false }));
|
||||
sprite.scale.set(7, 1.75, 1);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
function buildClouds(scene: THREE.Scene, rng: () => number) {
|
||||
const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1, transparent: true, opacity: 0.92 });
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const cloud = new THREE.Group();
|
||||
const puffs = 3 + Math.floor(rng() * 4);
|
||||
for (let p = 0; p < puffs; p++) {
|
||||
const puff = new THREE.Mesh(new THREE.SphereGeometry(8 + rng() * 10, 12, 10), mat);
|
||||
puff.position.set((p - puffs / 2) * 11, rng() * 4, (rng() - 0.5) * 10);
|
||||
puff.scale.y = 0.55;
|
||||
cloud.add(puff);
|
||||
}
|
||||
cloud.position.set((rng() - 0.5) * 1400, 90 + rng() * 60, (rng() - 0.5) * 800);
|
||||
scene.add(cloud);
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(rng: () => number): THREE.Group {
|
||||
const tree = new THREE.Group();
|
||||
const trunkH = 2 + rng() * 2;
|
||||
const trunk = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(0.25, 0.4, trunkH, 8),
|
||||
new THREE.MeshStandardMaterial({ color: 0x6e4a2f, roughness: 1 }),
|
||||
);
|
||||
trunk.position.y = trunkH / 2;
|
||||
tree.add(trunk);
|
||||
const greens = [0x2f7d32, 0x3c8d40, 0x4f9e3f, 0x2e6b28];
|
||||
const foliageMat = new THREE.MeshStandardMaterial({ color: greens[Math.floor(rng() * greens.length)], roughness: 1 });
|
||||
if (rng() > 0.5) {
|
||||
// conifer: stacked cones
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const cone = new THREE.Mesh(new THREE.ConeGeometry(2.4 - i * 0.6, 2.6, 10), foliageMat);
|
||||
cone.position.y = trunkH + i * 1.5 + 0.8;
|
||||
tree.add(cone);
|
||||
}
|
||||
} else {
|
||||
const crown = new THREE.Mesh(new THREE.SphereGeometry(2 + rng() * 1.4, 10, 8), foliageMat);
|
||||
crown.position.y = trunkH + 1.6;
|
||||
crown.scale.y = 0.9;
|
||||
tree.add(crown);
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
function buildAnimal(rng: () => number): THREE.Group {
|
||||
// low-poly cow / sheep
|
||||
const isCow = rng() > 0.45;
|
||||
const bodyColor = isCow ? (rng() > 0.5 ? 0x8b5a3c : 0x54432f) : 0xe8e4da;
|
||||
const g = new THREE.Group();
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyColor, roughness: 1 });
|
||||
const body = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.9, 0.8), bodyMat);
|
||||
body.position.y = 0.95;
|
||||
g.add(body);
|
||||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.5, 0.45), bodyMat);
|
||||
head.position.set(0.95, 1.25, 0);
|
||||
g.add(head);
|
||||
const legGeo = new THREE.BoxGeometry(0.16, 0.6, 0.16);
|
||||
for (const [x, z] of [[-0.6, -0.25], [-0.6, 0.25], [0.6, -0.25], [0.6, 0.25]]) {
|
||||
const leg = new THREE.Mesh(legGeo, bodyMat);
|
||||
leg.position.set(x, 0.3, z);
|
||||
g.add(leg);
|
||||
}
|
||||
if (isCow) {
|
||||
const patch = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.7, 0.5, 0.82),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 1 }),
|
||||
);
|
||||
patch.position.set(-0.3, 1.05, 0);
|
||||
g.add(patch);
|
||||
}
|
||||
const scale = isCow ? 1 : 0.6;
|
||||
g.scale.setScalar(scale);
|
||||
g.rotation.y = rng() * Math.PI * 2;
|
||||
return g;
|
||||
}
|
||||
|
||||
function buildScenery(scene: THREE.Scene, trackLen: number) {
|
||||
const rng = makeRng(1234567);
|
||||
buildClouds(scene, rng);
|
||||
const spread = Math.max(trackLen, 600);
|
||||
// grass tufts: darker green patches on the field
|
||||
const patchMat = new THREE.MeshStandardMaterial({ color: 0x4f8f3a, roughness: 1 });
|
||||
const patchGeo = new THREE.CircleGeometry(3, 8);
|
||||
const patches = new THREE.InstancedMesh(patchGeo, patchMat, 220);
|
||||
const m4 = new THREE.Matrix4();
|
||||
const q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0));
|
||||
const v = new THREE.Vector3();
|
||||
const s = new THREE.Vector3();
|
||||
for (let i = 0; i < 220; i++) {
|
||||
const z = (rng() > 0.5 ? 1 : -1) * (6 + rng() * 180);
|
||||
v.set((rng() - 0.5) * spread, 0.02, z);
|
||||
s.setScalar(0.5 + rng() * 2);
|
||||
m4.compose(v, q, s);
|
||||
patches.setMatrixAt(i, m4);
|
||||
}
|
||||
scene.add(patches);
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const tree = buildTree(rng);
|
||||
const z = (rng() > 0.5 ? 1 : -1) * (14 + rng() * 170);
|
||||
tree.position.set((rng() - 0.5) * spread, 0, z);
|
||||
tree.scale.setScalar(0.8 + rng() * 1.2);
|
||||
scene.add(tree);
|
||||
}
|
||||
// grazing herds
|
||||
for (let h = 0; h < 6; h++) {
|
||||
const cx = (rng() - 0.5) * spread;
|
||||
const cz = (rng() > 0.5 ? 1 : -1) * (25 + rng() * 120);
|
||||
const herd = 2 + Math.floor(rng() * 4);
|
||||
for (let a = 0; a < herd; a++) {
|
||||
const animal = buildAnimal(rng);
|
||||
animal.position.set(cx + (rng() - 0.5) * 14, 0, cz + (rng() - 0.5) * 12);
|
||||
scene.add(animal);
|
||||
}
|
||||
}
|
||||
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
|
||||
const mat = new THREE.PointsMaterial({ color: 0xffffff, size: 1.6, sizeAttenuation: false, transparent: true, opacity: 0.85 });
|
||||
scene.add(new THREE.Points(geo, mat));
|
||||
}
|
||||
|
||||
function buildTrack(scene: THREE.Scene, length: number) {
|
||||
@@ -89,7 +215,7 @@ function buildTrack(scene: THREE.Scene, length: number) {
|
||||
// ground
|
||||
const ground = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(2200, 800),
|
||||
new THREE.MeshStandardMaterial({ color: 0x101418, roughness: 1 }),
|
||||
new THREE.MeshStandardMaterial({ color: 0x5da043, roughness: 1 }),
|
||||
);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -0.02;
|
||||
@@ -230,6 +356,15 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3
|
||||
}
|
||||
}
|
||||
|
||||
// floating loaded/empty badge above the wagon
|
||||
const loaded = wagon.allocations.length > 0 || wagon.assignedWeightTons > 0;
|
||||
const labelText = loaded
|
||||
? `#${wagon.position ?? wagon.sequenceNo} · ${Math.round(wagon.assignedWeightTons)} t`
|
||||
: `#${wagon.position ?? wagon.sequenceNo} · EMPTY`;
|
||||
const label = makeLabelSprite(labelText, loaded ? "rgba(22, 130, 60, 0.95)" : "rgba(120, 128, 138, 0.9)");
|
||||
label.position.set(0, DECK_H + 5.4, 0);
|
||||
g.add(label);
|
||||
|
||||
addWheels(g, len, wheels);
|
||||
g.userData.length = len;
|
||||
g.userData.wagonId = wagon.id;
|
||||
@@ -331,35 +466,65 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
const canvasHostRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const cameraApiRef = useRef<{
|
||||
overview: () => void;
|
||||
front: () => void;
|
||||
rear: () => void;
|
||||
focusWagon: (wagonId: string) => void;
|
||||
} | null>(null);
|
||||
|
||||
const wagons = schedule.trainSet?.wagons ?? [];
|
||||
const moving = String(schedule.status).toUpperCase() === "DISPATCHED";
|
||||
const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null;
|
||||
const orderedWagons = [...wagons].sort(
|
||||
(a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo),
|
||||
);
|
||||
const selectedIndex = orderedWagons.findIndex((w) => w.id === selectedWagonId);
|
||||
|
||||
const stepWagon = (dir: 1 | -1) => {
|
||||
if (orderedWagons.length === 0) return;
|
||||
const next =
|
||||
selectedIndex < 0
|
||||
? dir === 1
|
||||
? 0
|
||||
: orderedWagons.length - 1
|
||||
: (selectedIndex + dir + orderedWagons.length) % orderedWagons.length;
|
||||
const wagon = orderedWagons[next];
|
||||
setSelectedWagonId(wagon.id);
|
||||
cameraApiRef.current?.focusWagon(wagon.id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const host = canvasHostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x02040a);
|
||||
scene.fog = new THREE.Fog(0x02040a, 250, 900);
|
||||
scene.background = new THREE.Color(0x9ed3f5);
|
||||
scene.fog = new THREE.Fog(0xbfe3f7, 350, 1100);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 2000);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.15;
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(host.clientWidth, host.clientHeight);
|
||||
host.appendChild(renderer.domElement);
|
||||
|
||||
// lights: moonlit night
|
||||
scene.add(new THREE.AmbientLight(0x8899bb, 0.35));
|
||||
const moon = new THREE.DirectionalLight(0xbfd0ff, 1.1);
|
||||
moon.position.set(-80, 120, 60);
|
||||
scene.add(moon);
|
||||
const warm = new THREE.PointLight(0xffc873, 0.6, 200);
|
||||
warm.position.set(0, 25, 30);
|
||||
scene.add(warm);
|
||||
|
||||
buildStars(scene);
|
||||
// lights: bright sunny day
|
||||
scene.add(new THREE.HemisphereLight(0xcfe8ff, 0x6f9c4e, 1.0));
|
||||
const sun = new THREE.DirectionalLight(0xfff4d6, 2.4);
|
||||
sun.position.set(-120, 180, 90);
|
||||
scene.add(sun);
|
||||
const fill = new THREE.DirectionalLight(0xdbeaff, 0.6);
|
||||
fill.position.set(100, 60, -80);
|
||||
scene.add(fill);
|
||||
// visible sun disc
|
||||
const sunDisc = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(18, 16, 16),
|
||||
new THREE.MeshBasicMaterial({ color: 0xfff2b0 }),
|
||||
);
|
||||
sunDisc.position.set(-500, 380, 300);
|
||||
scene.add(sunDisc);
|
||||
|
||||
// train assembly
|
||||
const wheels: THREE.Mesh[] = [];
|
||||
@@ -379,26 +544,69 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
train.add(lg);
|
||||
}
|
||||
const ordered = [...wagons].sort((a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo));
|
||||
const wagonGroups: Array<{ id: string; group: THREE.Group }> = [];
|
||||
for (const wagon of ordered) {
|
||||
const wg = buildWagon(wagon, wheels, pickables);
|
||||
wg.position.x = cursor - wg.userData.length / 2;
|
||||
cursor -= wg.userData.length + gap;
|
||||
train.add(wg);
|
||||
wagonGroups.push({ id: wagon.id, group: wg });
|
||||
}
|
||||
const trainLen = -cursor;
|
||||
train.position.x = trainLen / 2; // center train on origin
|
||||
scene.add(train);
|
||||
|
||||
const trackLen = Math.max(trainLen * 2.5, 400);
|
||||
buildTrack(scene, moving ? Math.max(trackLen, 1600) : trackLen);
|
||||
const finalTrackLen = moving ? Math.max(trackLen, 1600) : trackLen;
|
||||
buildTrack(scene, finalTrackLen);
|
||||
buildScenery(scene, finalTrackLen);
|
||||
|
||||
camera.position.set(trainLen * 0.12, 14, 42);
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.target.set(0, 2, 0);
|
||||
controls.maxPolarAngle = Math.PI / 2 - 0.03;
|
||||
controls.maxDistance = 500;
|
||||
controls.maxDistance = Math.max(600, trainLen * 1.6);
|
||||
controls.enableDamping = true;
|
||||
|
||||
// camera fly-to: goal recomputed each frame so it tracks a moving train
|
||||
let cameraGoal: (() => { pos: THREE.Vector3; target: THREE.Vector3 }) | null = null;
|
||||
const worldPos = new THREE.Vector3();
|
||||
const flyToObject = (obj: THREE.Object3D, dist: number, height: number) => {
|
||||
cameraGoal = () => {
|
||||
obj.getWorldPosition(worldPos);
|
||||
return {
|
||||
pos: new THREE.Vector3(worldPos.x + dist * 0.35, height, worldPos.z + dist),
|
||||
target: new THREE.Vector3(worldPos.x, 2.2, worldPos.z),
|
||||
};
|
||||
};
|
||||
};
|
||||
const frontGroup = train.children[0];
|
||||
const rearGroup = wagonGroups[wagonGroups.length - 1]?.group ?? frontGroup;
|
||||
cameraApiRef.current = {
|
||||
overview: () => {
|
||||
// side view far enough to fit the whole train in the 55° fov
|
||||
const dist = Math.max(40, (trainLen / 2 / Math.tan((55 * Math.PI) / 360)) * 1.15);
|
||||
cameraGoal = () => {
|
||||
train.getWorldPosition(worldPos);
|
||||
const cx = worldPos.x - trainLen / 2; // train extends in -x from its origin
|
||||
return {
|
||||
pos: new THREE.Vector3(cx, Math.max(20, dist * 0.25), dist),
|
||||
target: new THREE.Vector3(cx, 2, 0),
|
||||
};
|
||||
};
|
||||
},
|
||||
front: () => flyToObject(frontGroup, 26, 8),
|
||||
rear: () => flyToObject(rearGroup, 20, 7),
|
||||
focusWagon: (wagonId: string) => {
|
||||
const entry = wagonGroups.find((w) => w.id === wagonId);
|
||||
if (entry) flyToObject(entry.group, 16, 6);
|
||||
},
|
||||
};
|
||||
// user grabs the mouse → stop auto-flying, hand control back
|
||||
controls.addEventListener("start", () => {
|
||||
cameraGoal = null;
|
||||
});
|
||||
|
||||
// picking
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
@@ -424,6 +632,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
highlighted = wagonId ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) : null;
|
||||
if (highlighted) setEmissive(highlighted, true);
|
||||
setSelectedWagonId(wagonId ?? null);
|
||||
if (wagonId) cameraApiRef.current?.focusWagon(wagonId);
|
||||
};
|
||||
renderer.domElement.addEventListener("click", onClick);
|
||||
|
||||
@@ -448,12 +657,21 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
for (const w of wheels) w.rotation.y += (speed * dt) / WHEEL_R;
|
||||
if (train.position.x > trainLen / 2 + 120) train.position.x = trainLen / 2 - 120;
|
||||
}
|
||||
if (cameraGoal) {
|
||||
const goal = cameraGoal();
|
||||
const k = Math.min(1, dt * 3.2);
|
||||
camera.position.lerp(goal.pos, k);
|
||||
controls.target.lerp(goal.target, k);
|
||||
// static scene: release goal once settled so orbiting feels free again
|
||||
if (!moving && camera.position.distanceTo(goal.pos) < 0.15) cameraGoal = null;
|
||||
}
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
cameraApiRef.current = null;
|
||||
cancelAnimationFrame(raf);
|
||||
resizeObserver.disconnect();
|
||||
renderer.domElement.removeEventListener("click", onClick);
|
||||
@@ -510,8 +728,59 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
{schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group
|
||||
gap="xs"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 10,
|
||||
background: "rgba(15, 23, 42, 0.75)",
|
||||
borderRadius: 12,
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
leftSection={<Train size={14} />}
|
||||
onClick={() => cameraApiRef.current?.overview()}
|
||||
>
|
||||
Whole train
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
leftSection={<TrainFront size={14} />}
|
||||
onClick={() => cameraApiRef.current?.front()}
|
||||
>
|
||||
Locomotive
|
||||
</Button>
|
||||
<Button size="compact-sm" variant="default" onClick={() => cameraApiRef.current?.rear()}>
|
||||
Last wagon
|
||||
</Button>
|
||||
<Divider orientation="vertical" color="rgba(255,255,255,0.2)" />
|
||||
<Button size="compact-sm" variant="default" onClick={() => stepWagon(-1)} px={8}>
|
||||
<ChevronLeft size={16} />
|
||||
</Button>
|
||||
<Text size="sm" c="white" fw={600} style={{ minWidth: 90, textAlign: "center" }}>
|
||||
{selectedIndex >= 0
|
||||
? `Wagon ${orderedWagons[selectedIndex].position ?? orderedWagons[selectedIndex].sequenceNo} / ${orderedWagons.length}`
|
||||
: `${orderedWagons.length} wagons`}
|
||||
</Text>
|
||||
<Button size="compact-sm" variant="default" onClick={() => stepWagon(1)} px={8}>
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</Group>
|
||||
<Group gap="xs" style={{ position: "absolute", bottom: 16, left: 16, zIndex: 10 }}>
|
||||
<Text size="xs" c="gray.5">
|
||||
<Text
|
||||
size="xs"
|
||||
c="white"
|
||||
px="sm"
|
||||
py={4}
|
||||
style={{ background: "rgba(15, 23, 42, 0.6)", borderRadius: 8 }}
|
||||
>
|
||||
Drag to orbit · scroll to zoom · click a wagon for details
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
Reference in New Issue
Block a user