Compare commits

..

2 Commits

Author SHA1 Message Date
ae164c47a8 Clean 2026-04-15 17:01:55 +02:00
0bbd6a013d test 2026-04-08 20:52:52 +02:00
38 changed files with 0 additions and 391573 deletions

View File

@@ -1,14 +0,0 @@
module.exports = {
env: { browser: true, es2020: true, node: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
},
}

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
# SCM syntax highlighting & preventing 3-way merges
pixi.lock merge=binary linguist-language=YAML linguist-generated=true -diff

27
.gitignore vendored
View File

@@ -1,27 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# pixi environments
.pixi/*
!.pixi/config.toml

View File

@@ -1,11 +0,0 @@
FROM node:22 as node_builder
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN yarn
RUN yarn build
FROM nginxinc/nginx-unprivileged:1.29
COPY --from=node_builder /usr/src/app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

View File

@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="manifest" href="/manifest.json">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" sizes="196x196" href="favicon-196.png">
<link rel="apple-touch-icon" href="apple-icon-180.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>MapComponents + Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

12845
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
{
"name": "FossGIS Workshop 2026 3D Tiles",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@mapcomponents/deck-gl": "^1.8.9",
"@mapcomponents/react-maplibre": "^1.8.9",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"typescript": "^5.9.3",
"vite": "^8.0.2"
}
}

View File

@@ -1,18 +0,0 @@
version: 6
environments:
default:
channels:
- url: https://conda.anaconda.org/conda-forge/
options:
pypi-prerelease-mode: if-necessary-or-explicit
packages:
win-64:
- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-25.8.2-h80d1838_0.conda
packages:
- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-25.8.2-h80d1838_0.conda
sha256: 5e38e51da1aa4bc352db9b4cec1c3e25811de0f4408edaa24e009a64de6dbfdf
md5: e626ee7934e4b7cb21ce6b721cff8677
license: MIT
license_family: MIT
size: 31271315
timestamp: 1774517904472

View File

@@ -1,11 +0,0 @@
[workspace]
authors = ["Arne Zitting <arne.zitting@student.jade-hs.de>"]
channels = ["conda-forge"]
name = "Projekt Visualisierung - Code"
platforms = ["win-64"]
version = "0.1.0"
[tasks]
[dependencies]
nodejs = ">=25.8.2,<25.9"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,39 +0,0 @@
{
"short_name": "MapComponents app",
"name": "MapComponents + TypeScript + React App",
"icons": [
{
"src": "manifest-icon-192.maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "manifest-icon-192.maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "manifest-icon-512.maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "manifest-icon-512.maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

View File

@@ -1,44 +0,0 @@
import { useState } from "react";
import "./App.css";
import {
MapLibreMap,
Sidebar,
TopToolbar,
} from "@mapcomponents/react-maplibre";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
import LayerTree from "./components/LayerTree";
import Light from "./components/Light";
function App() {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<>
<MapLibreMap
options={{
style: "https://wms.wheregroup.com/tileserver/style/osm-bright.json",
zoom: 19,
center: [9.9347680519611, 51.531935000614226],
}}
style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}
/>
<TopToolbar
buttons={
<IconButton
onClick={() => setSidebarOpen((prev) => !prev)}
aria-label="toggle sidebar"
>
<MenuIcon />
</IconButton>
}
/>
<Sidebar open={sidebarOpen} setOpen={setSidebarOpen}>
<LayerTree />
</Sidebar>
<Light />
</>
);
}
export default App;

View File

@@ -1,35 +0,0 @@
import { useEffect, useState } from "react";
import { MlGeoJsonLayer } from "@mapcomponents/react-maplibre";
import type { FeatureCollection } from "geojson";
export default function HedgeLayer() {
const [geojson, setGeojson] = useState<FeatureCollection | null>(null);
useEffect(() => {
fetch("/assets/hedges.geojson")
.then((res) => res.json())
.then(setGeojson);
}, []);
if (!geojson) return null;
return (
<MlGeoJsonLayer
layerId="hedge"
geojson={geojson}
type="line"
insertBeforeLayer="waterway-name"
options={{
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": "#4caf50",
"line-width": 6,
"line-opacity": 0.9,
},
}}
/>
);
}

View File

@@ -1,58 +0,0 @@
import { useState } from "react";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Checkbox from "@mui/material/Checkbox";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import ThreeDTilesLayer from "./ThreeDTilesLayer/ThreeDTilesLayer_1";
//import TreeLayer from "./TreeLayer/TreeLayer_1";
//import HedgeLayer from "./HedgeLayer/HedgeLayer";
const LAYERS = [
//{ id: "hedges", label: "Hedges", component: <HedgeLayer key="hedges" /> },
{ id: "tiles3d", label: "3D Buildings", component: <ThreeDTilesLayer key="tiles3d" /> },
//{ id: "trees", label: "Trees", component: <TreeLayer sizeScale={0.5} key="trees" /> },
] as const;
export default function LayerTree() {
const [visible, setVisible] = useState<Record<string, boolean>>(
Object.fromEntries(LAYERS.map((l) => [l.id, true])),
);
const toggle = (id: string) => setVisible((v) => ({ ...v, [id]: !v[id] }));
return (
<>
<Typography
variant="overline"
sx={{ px: 2, pt: 2, display: "block", color: "text.secondary" }}
>
Layers
</Typography>
<List dense disablePadding>
{LAYERS.map((layer) => (
<ListItem
key={layer.id}
onClick={() => toggle(layer.id)}
sx={{ cursor: "pointer" }}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<Checkbox
edge="start"
checked={!!visible[layer.id]}
disableRipple
size="small"
/>
</ListItemIcon>
<ListItemText primary={layer.label} />
</ListItem>
))}
</List>
<Divider sx={{ my: 1 }} />
{LAYERS.map((layer) => visible[layer.id] && layer.component)}
</>
);
}

View File

@@ -1,24 +0,0 @@
import { useEffect } from "react";
import { LightingEffect, AmbientLight, DirectionalLight } from "@deck.gl/core";
import { useDeckGl } from "@mapcomponents/deck-gl";
const effect = new LightingEffect({
ambientLight: new AmbientLight({ color: [255, 255, 255], intensity: 1.8 }),
sunLight: new DirectionalLight({
color: [255, 240, 200],
intensity: 2.0,
direction: [-2, -4, -3],
}),
});
export default function LightingSetup() {
const { addEffect, removeEffect } = useDeckGl();
useEffect(() => {
addEffect(effect);
return () => removeEffect(effect);
}, []);
return null;
}

View File

@@ -1,15 +0,0 @@
import Ml3DTileLayer from "../../lib/MlTile3DLayer/Ml3DTilesLayer";
type Props = {};
export default function ThreeDTilesLayer({}: Props) {
return (
<>
<Ml3DTileLayer
id="3d-tiles-layer"
data="https://sgx.geodatenzentrum.de/gdz_basemapde_3d_gebaeude/lod2_4978_null.json"
//beforeId="waterway-name"
/>
</>
);
}

View File

@@ -1,35 +0,0 @@
import { useCallback, useRef } from "react";
import Ml3DTileLayer from "../../lib/MlTile3DLayer/Ml3DTilesLayer";
import type { Tile3D } from "@loaders.gl/tiles";
import type { Color } from "@deck.gl/core";
type Props = {};
export default function ThreeDTilesLayer({}: Props) {
const selectedRef = useRef<string | null>(null);
const getFeatureColor = useCallback(
(featureId: number, tile: Tile3D): Color => {
const props = tile.content?.propertyTable?.[featureId];
const surface = (props?.surface as string)?.toLowerCase();
if (surface === "roof") {
return [255, 80, 80];
}
return [235, 235, 235];
},
[],
);
return (
<>
<Ml3DTileLayer
id="3d-tiles-layer"
data="https://sgx.geodatenzentrum.de/gdz_basemapde_3d_gebaeude/lod2_4978_null.json"
getFeatureColor={() => [0,200,0]}
//getFeatureColor={getFeatureColor}
beforeId="waterway-name"
/>
</>
);
}

View File

@@ -1,56 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { Enhanced3DTilePickingInfo } from "../../lib/MlTile3DLayer/Tiles3DLayer2";
import Ml3DTileLayer from "../../lib/MlTile3DLayer/Ml3DTilesLayer";
import type { Tile3D } from "@loaders.gl/tiles";
import type { Color } from "@deck.gl/core";
type Props = {};
export default function ThreeDTilesLayer({}: Props) {
const [colorVersion, setColorVersion] = useState(0);
const selectedRef = useRef<string | null>(null);
const getFeatureColor = useCallback(
(featureId: number, tile: Tile3D): Color => {
const props = tile.content?.propertyTable?.[featureId];
const gmlId = props?.gml_id as string | undefined;
const surface = (props?.surface as string)?.toLowerCase();
if (selectedRef.current && gmlId === selectedRef.current) {
return [0, 200, 0];
}
if (surface === "roof") {
return [255, 80, 80];
}
return [235, 235, 235];
},
[],
);
const onClick = useCallback((info: unknown) => {
const pickInfo = info as Enhanced3DTilePickingInfo;
if (!pickInfo.picked) return;
const gmlId = pickInfo.featureProperties?.gml_id as string | undefined;
if (gmlId) {
const isDeselect = selectedRef.current === gmlId;
selectedRef.current = isDeselect ? null : gmlId;
setColorVersion((v) => v + 1);
}
}, []);
return (
<>
<Ml3DTileLayer
id="3d-tiles-layer"
data="https://sgx.geodatenzentrum.de/gdz_basemapde_3d_gebaeude/lod2_4978_null.json"
pickable={true}
getFeatureColor={getFeatureColor}
//onClick={onClick}
onClick={(info) => console.log(info)}
updateTrigger={colorVersion}
beforeId="waterway-name"
/>
</>
);
}

View File

@@ -1,106 +0,0 @@
import { useCallback, useRef, useState } from "react";
import { Enhanced3DTilePickingInfo } from "../../lib/MlTile3DLayer/Tiles3DLayer2";
import Ml3DTileLayer from "../../lib/MlTile3DLayer/Ml3DTilesLayer";
import type { Tile3D } from "@loaders.gl/tiles";
import type { Color } from "@deck.gl/core";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import Paper from "@mui/material/Paper";
type Props = {};
export default function ThreeDTilesLayer({}: Props) {
const [colorVersion, setColorVersion] = useState(0);
const selectedRef = useRef<string | null>(null);
const [selectedProps, setSelectedProps] = useState<Record<
string,
unknown
> | null>(null);
const getFeatureColor = useCallback(
(featureId: number, tile: Tile3D): Color => {
const props = tile.content?.propertyTable?.[featureId];
const gmlId = props?.gml_id as string | undefined;
const surface = (props?.surface as string)?.toLowerCase();
if (selectedRef.current && gmlId === selectedRef.current) {
return [0, 200, 0];
}
if (surface === "roof") {
return [255, 80, 80];
}
return [235, 235, 235];
},
[],
);
const onClick = useCallback((info: unknown) => {
const pickInfo = info as Enhanced3DTilePickingInfo;
if (!pickInfo.picked) return;
const gmlId = pickInfo.featureProperties?.gml_id as string | undefined;
if (gmlId) {
const isDeselect = selectedRef.current === gmlId;
selectedRef.current = isDeselect ? null : gmlId;
setSelectedProps(
isDeselect ? null : (pickInfo.featureProperties ?? null),
);
setColorVersion((v) => v + 1);
}
}, []);
return (
<>
<Ml3DTileLayer
id="3d-tiles-layer"
data="https://sgx.geodatenzentrum.de/gdz_basemapde_3d_gebaeude/lod2_4978_null.json"
pickable={true}
getFeatureColor={getFeatureColor}
onClick={onClick}
updateTrigger={colorVersion}
beforeId="waterway-name"
/>
{selectedProps && (
<Paper variant="outlined" sx={{ m: 2, overflow: "hidden" }}>
<Box sx={{ px: 2, py: 1.5, bgcolor: "primary.main" }}>
<Typography
variant="subtitle2"
sx={{ color: "primary.contrastText", fontWeight: 700 }}
>
Selected Building
</Typography>
</Box>
<Divider />
<Box sx={{ px: 2, py: 1 }}>
{Object.entries(selectedProps).map(([key, value]) => (
<Box
key={key}
sx={{
display: "flex",
justifyContent: "space-between",
py: 0.5,
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mr: 2, flexShrink: 0 }}
>
{key}
</Typography>
<Typography
variant="body2"
sx={{ textAlign: "right", wordBreak: "break-all" }}
>
{String(value)}
</Typography>
</Box>
))}
</Box>
</Paper>
)}
</>
);
}

View File

@@ -1,78 +0,0 @@
import { useEffect, useState } from "react";
import { MlSceneGraphLayer } from "@mapcomponents/deck-gl";
interface TreeFeature {
type: "Feature";
properties: {
leaf_type?: string;
height?: string | number;
[key: string]: unknown;
};
geometry: {
type: "Point";
coordinates: [number, number] | [number, number, number];
};
}
const getPosition = (d: unknown): [number, number, number] => {
const f = d as TreeFeature;
const [lon, lat] = f.geometry.coordinates;
return [lon, lat, 0];
};
interface TreeLayerProps {
mapId?: string;
sizeScale?: number;
}
const TreeLayer = ({ mapId, sizeScale = 15 }: TreeLayerProps) => {
const [pineFeatures, setPineFeatures] = useState<TreeFeature[]>([]);
const [broadleafFeatures, setBroadleafFeatures] = useState<TreeFeature[]>([]);
useEffect(() => {
fetch("/assets/trees.geojson")
.then((res) => res.json())
.then((data: { features: TreeFeature[] }) => {
const features = data.features;
setPineFeatures(
features.filter((f) => f.properties.leaf_type === "needleleaved")
);
setBroadleafFeatures(
features.filter((f) => f.properties.leaf_type !== "needleleaved")
);
});
}, []);
return (
<>
{pineFeatures.length > 0 && (
<MlSceneGraphLayer
id="pine-trees"
mapId={mapId}
data={pineFeatures}
scenegraph="/assets/low_poly_pine.glb"
getPosition={getPosition}
getOrientation={[0, 0, 90]}
sizeScale={sizeScale}
_lighting="pbr"
pickable
/>
)}
{broadleafFeatures.length > 0 && (
<MlSceneGraphLayer
id="broadleaf-trees"
mapId={mapId}
data={broadleafFeatures}
scenegraph="/assets/low_poly_tree.glb"
getPosition={getPosition}
getOrientation={[0, 0, 90]}
sizeScale={sizeScale}
_lighting="pbr"
pickable
/>
)}
</>
);
};
export default TreeLayer;

View File

@@ -1,152 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { MlSceneGraphLayer } from "@mapcomponents/deck-gl";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
interface TreeFeature {
type: "Feature";
properties: {
leaf_type?: string;
height?: string | number;
[key: string]: unknown;
};
geometry: {
type: "Point";
coordinates: [number, number] | [number, number, number];
};
}
const getPosition = (d: unknown): [number, number, number] => {
const f = d as TreeFeature;
const [lon, lat] = f.geometry.coordinates;
return [lon, lat, 0];
};
interface TreeLayerProps {
mapId?: string;
sizeScale?: number;
}
const TreeLayer = ({ mapId, sizeScale = 15 }: TreeLayerProps) => {
const [pineFeatures, setPineFeatures] = useState<TreeFeature[]>([]);
const [broadleafFeatures, setBroadleafFeatures] = useState<TreeFeature[]>([]);
const [selectedFeature, setSelectedFeature] = useState<TreeFeature | null>(null);
const selectedProps = selectedFeature ? (selectedFeature.properties as Record<string, unknown>) : null;
const onClick = useCallback((info: unknown) => {
const pickInfo = info as { picked?: boolean; object?: TreeFeature };
if (!pickInfo.picked || !pickInfo.object) return;
const feature = pickInfo.object;
setSelectedFeature((prev) =>
JSON.stringify(prev?.geometry.coordinates) === JSON.stringify(feature.geometry.coordinates)
? null
: feature
);
}, []);
useEffect(() => {
fetch("/assets/trees.geojson")
.then((res) => res.json())
.then((data: { features: TreeFeature[] }) => {
const features = data.features;
setPineFeatures(
features.filter((f) => f.properties.leaf_type === "needleleaved")
);
setBroadleafFeatures(
features.filter((f) => f.properties.leaf_type !== "needleleaved")
);
});
}, []);
const isSelected = useCallback(
(f: TreeFeature) =>
selectedFeature !== null &&
JSON.stringify(f.geometry.coordinates) ===
JSON.stringify(selectedFeature.geometry.coordinates),
[selectedFeature]
);
const yellowColor: [number, number, number, number] = [255, 255, 0, 255];
return (
<>
{pineFeatures.length > 0 && (
<MlSceneGraphLayer
id="pine-trees"
mapId={mapId}
data={pineFeatures.filter((f) => !isSelected(f)) as unknown[]}
scenegraph="/assets/low_poly_pine.glb"
getPosition={getPosition}
getOrientation={[0, 0, 90]}
sizeScale={sizeScale}
_lighting="pbr"
updateTriggers={{ data: selectedFeature }}
pickable
onClick={onClick}
/>
)}
{broadleafFeatures.length > 0 && (
<MlSceneGraphLayer
id="broadleaf-trees"
mapId={mapId}
data={broadleafFeatures.filter((f) => !isSelected(f)) as unknown[]}
scenegraph="/assets/low_poly_tree.glb"
getPosition={getPosition}
getOrientation={[0, 0, 90]}
sizeScale={sizeScale}
_lighting="pbr"
updateTriggers={{ data: selectedFeature }}
pickable
onClick={onClick}
/>
)}
{selectedFeature && (
<MlSceneGraphLayer
id="selected-tree"
mapId={mapId}
data={[selectedFeature] as unknown[]}
scenegraph={
selectedFeature.properties.leaf_type === "needleleaved"
? "/assets/low_poly_pine.glb"
: "/assets/low_poly_tree.glb"
}
getPosition={getPosition}
getOrientation={[0, 0, 90]}
sizeScale={sizeScale}
_lighting="flat"
getColor={() => yellowColor}
pickable
onClick={onClick}
/>
)}
{selectedProps && (
<Paper variant="outlined" sx={{ m: 2, overflow: "hidden" }}>
<Box sx={{ px: 2, py: 1.5, bgcolor: "primary.main" }}>
<Typography variant="subtitle2" sx={{ color: "primary.contrastText", fontWeight: 700 }}>
Selected Tree
</Typography>
</Box>
<Divider />
<Box sx={{ px: 2, py: 1 }}>
{Object.entries(selectedProps).map(([key, value]) => (
<Box key={key} sx={{ display: "flex", justifyContent: "space-between", py: 0.5 }}>
<Typography variant="body2" color="text.secondary" sx={{ mr: 2, flexShrink: 0 }}>
{key}
</Typography>
<Typography variant="body2" sx={{ textAlign: "right", wordBreak: "break-all" }}>
{String(value)}
</Typography>
</Box>
))}
</Box>
</Paper>
)}
</>
);
};
export default TreeLayer;

View File

@@ -1,3 +0,0 @@
body {
padding: 0;
}

View File

@@ -1,93 +0,0 @@
// This file is inspired by @mapcomponents/deck-gl's Ml3DTileLayer
// (https://github.com/mapcomponents/react-map-components-maplibre)
//
// Original work: Copyright (c) 2021 WhereGroup GmbH — MIT License
// Modified to use the custom Tile3DLayer2 class (Tiles3DLayer2.tsx) instead of
// deck.gl's built-in Tile3DLayer, enabling in-place layer replacement without
// restarting tile loading and exposing the updateTrigger prop.
//
// SPDX-License-Identifier: MIT
import { useContext, useEffect, useMemo, useRef } from 'react';
import { useMap } from '@mapcomponents/react-maplibre';
//import { Tile3DLayer, Tile3DLayerProps } from '@deck.gl/geo-layers';
import { default as Tile3DLayer, Tile3DLayer2Props } from './Tiles3DLayer2';
import { DeckGlContext } from '@mapcomponents/deck-gl';
import type { Layer } from '@deck.gl/core';
export interface Ml3DTileLayerProps extends Tile3DLayer2Props {
/**
* Id of the target MapLibre instance in mapContext
*/
mapId?: string;
/**
* Id of an existing layer in the mapLibre instance to help specify the layer order
* This layer will be visually beneath the layer with the "beforeId" id.
*/
beforeId?: string;
}
const Ml3DTileLayer = (props: Ml3DTileLayerProps) => {
const { mapId, ...Ml3DTileProps } = props;
const mapHook = useMap({ mapId: mapId });
const deckGlContext = useContext(DeckGlContext);
const layerRef = useRef<Layer | null>(null);
const tile3dLayer = useMemo(() => {
if (!Ml3DTileProps.data) return null;
else
return new Tile3DLayer({
...Ml3DTileProps,
});
}, [
Ml3DTileProps.data,
Ml3DTileProps.id,
Ml3DTileProps.pickable,
Ml3DTileProps.onTileLoad,
Ml3DTileProps.onTileUnload,
Ml3DTileProps.loadOptions,
Ml3DTileProps.loaders,
Ml3DTileProps.visible,
Ml3DTileProps.opacity,
Ml3DTileProps.pointSize,
Ml3DTileProps.beforeId,
Ml3DTileProps.getFeatureColor,
Ml3DTileProps.onClick,
Ml3DTileProps.updateTrigger,
]);
// Add or replace the layer in the deck.gl layer array in-place.
// This avoids remove+add which would destroy the tileset via MapboxOverlay.
useEffect(() => {
if (!mapHook.map || !tile3dLayer) return;
const prev = layerRef.current;
layerRef.current = tile3dLayer;
deckGlContext.setDeckGlLayerArray((layers) => {
if (prev) {
// Replace old layer reference in-place
return layers.map((l) => (l === prev ? tile3dLayer : l));
}
// First mount: append
return [...layers, tile3dLayer];
});
}, [mapHook.map, tile3dLayer]);
// Remove layer only on unmount
useEffect(() => {
return () => {
const layer = layerRef.current;
if (layer) {
deckGlContext.setDeckGlLayerArray((layers) =>
layers.filter((l) => l !== layer)
);
layerRef.current = null;
}
};
}, []);
return <></>;
};
export default Ml3DTileLayer;

View File

@@ -1,544 +0,0 @@
// This file is derived from deck.gl's Tile3DLayer
// (https://github.com/visgl/deck.gl/blob/master/modules/geo-layers/src/tile-3d-layer/tile-3d-layer.ts)
//
// Original work: Copyright (c) Vis.gl contributors — MIT License
// Modified work: per-feature GPU picking, per-feature color via getFeatureColor,
// glTF→MESH conversion with EXT_structural_metadata property table support,
// and enhanced PickingInfo type (Enhanced3DTilePickingInfo).
//
// SPDX-License-Identifier: MIT
import {Geometry} from '@luma.gl/engine';
import {
Accessor,
Color,
CompositeLayer,
CompositeLayerProps,
COORDINATE_SYSTEM,
FilterContext,
GetPickingInfoParams,
Layer,
LayersList,
log,
PickingInfo,
UpdateParameters,
Viewport,
DefaultProps,
} from '@deck.gl/core';
import {PointCloudLayer} from '@deck.gl/layers';
import {ScenegraphLayer} from '@deck.gl/mesh-layers';
// Internal MeshLayer supports featureIds + PBR; the public SimpleMeshLayer does not.
import MeshLayer from '../../../node_modules/@deck.gl/geo-layers/dist/mesh-layer/mesh-layer.js';
import {load} from '@loaders.gl/core';
import type {MeshAttributes} from '@loaders.gl/schema';
import {Tileset3D, Tile3D, TILE_TYPE} from '@loaders.gl/tiles';
import {Tiles3DLoader} from '@loaders.gl/3d-tiles';
export type Enhanced3DTilePickingInfo = PickingInfo & {
sourceTile: Tile3D | null;
featureId?: number;
featureProperties?: Record<string, unknown>;
};
const SINGLE_DATA = [0];
export type Tile3DLayer2Props<DataT = unknown> = _Tile3DLayer2Props<DataT> &
CompositeLayerProps;
type _Tile3DLayer2Props<DataT> = {
data: string;
getPointColor?: Accessor<DataT, Color>;
pointSize?: number;
/** @deprecated Use `loaders` instead */
loader?: typeof Tiles3DLoader;
onTilesetLoad?: (tileset: Tileset3D) => void;
onTileLoad?: (tile: Tile3D) => void;
onTileUnload?: (tile: Tile3D) => void;
onTileError?: (tile: Tile3D, url: string, message: string) => void;
/** Per-feature color function. Receives featureId and owning Tile3D. Returns [r,g,b] or [r,g,b,a] in 0255. */
getFeatureColor?: (featureId: number, tile: Tile3D) => Color;
/** Tile-level fallback color for MESH tiles when getFeatureColor is not provided. */
_getMeshColor?: (tile: Tile3D) => Color;
/** Bump to force a per-feature color rebuild without restarting tile loading. */
updateTrigger?: number;
};
const defaultProps: DefaultProps<Tile3DLayer2Props> = {
getPointColor: {type: 'accessor', value: [0, 0, 0, 255]},
pointSize: 1.0,
data: '',
loader: Tiles3DLoader,
onTilesetLoad: {type: 'function', value: () => {}},
onTileLoad: {type: 'function', value: () => {}},
onTileUnload: {type: 'function', value: () => {}},
onTileError: {type: 'function', value: () => {}},
getFeatureColor: {type: 'function', value: null, optional: true} as any,
_getMeshColor: {type: 'function', value: () => [255, 255, 255]},
updateTrigger: 0,
};
/**
* Drop-in replacement for deck.gl's Tile3DLayer with per-feature GPU picking
* and per-feature mesh coloring. onClick/onHover receive featureId and
* featureProperties in addition to the tile object.
*/
export default class Tile3DLayer2<
DataT = any,
ExtraPropsT extends {} = {},
> extends CompositeLayer<ExtraPropsT & Required<_Tile3DLayer2Props<DataT>>> {
static defaultProps = defaultProps;
static layerName = 'Tile3DLayer2';
declare state: {
activeViewports: Record<string, Viewport>;
frameNumber?: number;
lastUpdatedViewports: Record<string, Viewport> | null;
layerMap: Record<string, {tile: Tile3D; layer?: Layer | null; needsUpdate?: boolean}>;
tileset3d: Tileset3D | null;
};
initializeState(): void {
if ('onTileLoadFail' in this.props) {
log.removed('onTileLoadFail', 'onTileError')();
}
this.state = {
layerMap: {},
tileset3d: null,
activeViewports: {},
lastUpdatedViewports: null,
};
}
get isLoaded(): boolean {
return Boolean(this.state?.tileset3d?.isLoaded() && super.isLoaded);
}
shouldUpdateState({changeFlags}: UpdateParameters<this>): boolean {
return changeFlags.somethingChanged;
}
updateState({props, oldProps, changeFlags}: UpdateParameters<this>): void {
if (props.data && props.data !== oldProps.data) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this._loadTileset(props.data);
}
if (changeFlags.viewportChanged) {
const {activeViewports} = this.state;
if (Object.keys(activeViewports).length) {
this._updateTileset(activeViewports);
this.state.lastUpdatedViewports = activeViewports;
this.state.activeViewports = {};
}
}
if (changeFlags.propsChanged) {
for (const key in this.state.layerMap) {
this.state.layerMap[key].needsUpdate = true;
}
this.setNeedsUpdate();
}
}
activateViewport(viewport: Viewport): void {
const {activeViewports, lastUpdatedViewports} = this.state;
this.internalState!.viewport = viewport;
activeViewports[viewport.id] = viewport;
const lastViewport = lastUpdatedViewports?.[viewport.id];
if (!lastViewport || !viewport.equals(lastViewport)) {
this.setChangeFlags({viewportChanged: true});
this.setNeedsUpdate();
}
}
getPickingInfo({info, sourceLayer}: GetPickingInfoParams): Enhanced3DTilePickingInfo {
const sourceTile: Tile3D | null = sourceLayer
? (sourceLayer.props as any).tile ?? null
: null;
const result = info as Enhanced3DTilePickingInfo;
result.sourceTile = sourceTile;
if (info.picked && sourceTile) {
result.object = sourceTile;
const featureIds: ArrayLike<number> | undefined = sourceTile.content?.featureIds;
if (featureIds && featureIds.length > 0) {
const featureId = info.index;
if (featureId != null && featureId >= 0) {
result.featureId = featureId;
result.featureProperties = resolveFeatureProperties(sourceTile, featureId);
}
}
}
return result;
}
filterSubLayer({layer, viewport}: FilterContext): boolean {
const {tile} = layer.props as unknown as {tile: Tile3D};
return tile.selected && tile.viewportIds.includes(viewport.id);
}
protected _updateAutoHighlight(info: PickingInfo): void {
const sourceTile = (info as Enhanced3DTilePickingInfo).sourceTile;
const layerCache = sourceTile ? this.state.layerMap[sourceTile.id] : null;
if (layerCache?.layer) {
(layerCache.layer as any).updateAutoHighlight(info);
}
}
private async _loadTileset(tilesetUrl: string): Promise<void> {
const {loadOptions = {}} = this.props;
// @ts-ignore — support both deprecated `loader` and `loaders`
const loaders = this.props.loader || this.props.loaders;
const loader = Array.isArray(loaders) ? loaders[0] : loaders;
const options: any = {loadOptions: {...loadOptions}};
let actualTilesetUrl = tilesetUrl;
if (loader?.preload) {
const preloadOptions = await loader.preload(tilesetUrl, loadOptions);
if (preloadOptions.url) actualTilesetUrl = preloadOptions.url;
if (preloadOptions.headers) {
options.loadOptions.fetch = {
...options.loadOptions.fetch,
headers: preloadOptions.headers,
};
}
Object.assign(options, preloadOptions);
}
const tilesetJson = await load(actualTilesetUrl, loader, options.loadOptions);
const tileset3d = new Tileset3D(tilesetJson, {
onTileLoad: this._onTileLoad.bind(this),
onTileUnload: this._onTileUnload.bind(this),
onTileError: this.props.onTileError,
...options,
});
this.setState({tileset3d, layerMap: {}});
this._updateTileset(this.state.activeViewports);
this.props.onTilesetLoad(tileset3d);
}
private _onTileLoad(tileHeader: Tile3D): void {
this._convertScenegraphToMesh(tileHeader);
this.props.onTileLoad(tileHeader);
this._updateTileset(this.state.lastUpdatedViewports);
this.setNeedsUpdate();
}
private _onTileUnload(tileHeader: Tile3D): void {
delete this.state.layerMap[tileHeader.id];
this.props.onTileUnload(tileHeader);
}
private _updateTileset(viewports: Record<string, Viewport> | null): void {
if (!viewports) return;
const {tileset3d} = this.state;
const {timeline} = this.context;
if (!timeline || !Object.keys(viewports).length || !tileset3d) return;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
tileset3d.selectTiles(Object.values(viewports)).then((frameNumber) => {
if (this.state.frameNumber !== frameNumber) {
this.setState({frameNumber});
}
});
}
/** Converts glTF SCENEGRAPH tiles to MESH in-place to enable per-feature picking and coloring. */
private _convertScenegraphToMesh(tileHeader: Tile3D): void {
const content = tileHeader.content;
if (!content?.gltf || content.attributes) return;
const gltf = content.gltf;
const prim = gltf.meshes?.[0]?.primitives?.[0];
if (!prim?.attributes?.POSITION) return;
const posAttr = prim.attributes.POSITION;
const normAttr = prim.attributes.NORMAL;
const featureIdRaw =
prim.attributes._FEATURE_ID_0 ||
prim.attributes._BATCHID ||
prim.attributes.BATCHID;
const rawPositions: Float32Array = posAttr.value || posAttr;
const rawNormals: Float32Array | undefined = normAttr?.value || normAttr;
const indices = prim.indices?.value || prim.indices;
const node = gltf.nodes?.find((n: any) => n.mesh != null);
const nodeMatrix: number[] | undefined = node?.matrix;
const positions = new Float32Array(rawPositions.length);
const normals = rawNormals ? new Float32Array(rawNormals.length) : undefined;
if (nodeMatrix && !isIdentityMatrix(nodeMatrix)) {
// Apply glTF node transform (affine: M * [x, y, z, 1])
for (let i = 0; i < rawPositions.length; i += 3) {
const x = rawPositions[i], y = rawPositions[i + 1], z = rawPositions[i + 2];
positions[i] = nodeMatrix[0] * x + nodeMatrix[4] * y + nodeMatrix[8] * z + nodeMatrix[12];
positions[i + 1] = nodeMatrix[1] * x + nodeMatrix[5] * y + nodeMatrix[9] * z + nodeMatrix[13];
positions[i + 2] = nodeMatrix[2] * x + nodeMatrix[6] * y + nodeMatrix[10] * z + nodeMatrix[14];
}
// Apply upper-3x3 to normals (no translation)
if (rawNormals && normals) {
for (let i = 0; i < rawNormals.length; i += 3) {
const x = rawNormals[i], y = rawNormals[i + 1], z = rawNormals[i + 2];
normals[i] = nodeMatrix[0] * x + nodeMatrix[4] * y + nodeMatrix[8] * z;
normals[i + 1] = nodeMatrix[1] * x + nodeMatrix[5] * y + nodeMatrix[9] * z;
normals[i + 2] = nodeMatrix[2] * x + nodeMatrix[6] * y + nodeMatrix[10] * z;
}
}
} else {
positions.set(rawPositions);
if (rawNormals && normals) normals.set(rawNormals);
}
content.attributes = {
positions: {size: 3, value: positions},
...(normals ? {normals: {size: 3, value: normals}} : {}),
};
content.indices = indices || undefined;
if (prim.material) content.material = prim.material;
if (featureIdRaw) {
const rawValues = featureIdRaw.value || featureIdRaw;
const featureIds = new Uint32Array(rawValues.length);
for (let i = 0; i < rawValues.length; i++) featureIds[i] = rawValues[i];
content.featureIds = featureIds;
}
// Build per-feature property table from EXT_structural_metadata.
// loaders.gl resolves the binary arrays into typed JS arrays at load time.
const pt = gltf.extensions?.EXT_structural_metadata?.propertyTables?.[0];
if (pt) {
content.propertyTable = Array.from({length: pt.count || 0}, (_, i) => {
const row: Record<string, unknown> = {};
for (const [key, prop] of Object.entries(pt.properties || {})) {
const data = (prop as any).data;
if (Array.isArray(data)) row[key] = data[i];
}
return row;
});
}
(tileHeader as any).type = TILE_TYPE.MESH;
}
private _getSubLayer(
tileHeader: Tile3D,
oldLayer?: Layer,
): MeshLayer<DataT> | PointCloudLayer<DataT> | ScenegraphLayer<DataT> | null {
if (!tileHeader.content) return null;
switch (tileHeader.type as TILE_TYPE) {
case TILE_TYPE.POINTCLOUD:
return this._makePointCloudLayer(tileHeader, oldLayer as PointCloudLayer<DataT>);
case TILE_TYPE.SCENEGRAPH:
return this._make3DModelLayer(tileHeader);
case TILE_TYPE.MESH:
return this._makeSimpleMeshLayer(tileHeader, oldLayer as MeshLayer<DataT>);
default:
throw new Error(`Tile3DLayer2: unsupported tile type ${tileHeader.content.type}`);
}
}
private _makePointCloudLayer(
tileHeader: Tile3D,
oldLayer?: PointCloudLayer<DataT>,
): PointCloudLayer<DataT> | null {
const {attributes, pointCount, constantRGBA, cartographicOrigin, modelMatrix} =
tileHeader.content;
const {positions, normals, colors} = attributes;
if (!positions) return null;
const data = (oldLayer && oldLayer.props.data) || {
header: {vertexCount: pointCount},
attributes: {POSITION: positions, NORMAL: normals, COLOR_0: colors},
};
const {pointSize, getPointColor} = this.props;
const SubLayerClass = this.getSubLayerClass('pointcloud', PointCloudLayer);
return new SubLayerClass(
{pointSize},
this.getSubLayerProps({id: 'pointcloud'}),
{
id: `${this.id}-pointcloud-${tileHeader.id}`,
tile: tileHeader,
data,
coordinateSystem: COORDINATE_SYSTEM.METER_OFFSETS,
coordinateOrigin: cartographicOrigin,
modelMatrix,
getColor: constantRGBA || getPointColor,
_offset: 0,
},
);
}
private _make3DModelLayer(tileHeader: Tile3D): ScenegraphLayer<DataT> {
const {gltf, instances, cartographicOrigin, modelMatrix} = tileHeader.content;
const SubLayerClass = this.getSubLayerClass('scenegraph', ScenegraphLayer);
return new SubLayerClass(
{_lighting: 'pbr'},
this.getSubLayerProps({id: 'scenegraph'}),
{
id: `${this.id}-scenegraph-${tileHeader.id}`,
tile: tileHeader,
data: instances || SINGLE_DATA,
scenegraph: gltf,
coordinateSystem: COORDINATE_SYSTEM.METER_OFFSETS,
coordinateOrigin: cartographicOrigin,
modelMatrix,
getTransformMatrix: (instance: any) => instance.modelMatrix,
getPosition: [0, 0, 0],
_offset: 0,
},
);
}
private _makeSimpleMeshLayer(
tileHeader: Tile3D,
oldLayer?: MeshLayer<DataT>,
): MeshLayer<DataT> {
const content = tileHeader.content;
const {
attributes,
indices,
modelMatrix,
cartographicOrigin,
coordinateSystem = COORDINATE_SYSTEM.METER_OFFSETS,
material,
featureIds,
} = content;
const {getFeatureColor, _getMeshColor} = this.props;
const mergedAttributes = buildMeshGeometry(attributes, featureIds, getFeatureColor, tileHeader);
const geometry =
(!getFeatureColor && oldLayer && oldLayer.props.mesh) ||
new Geometry({topology: 'triangle-list', attributes: mergedAttributes, indices});
const SubLayerClass = this.getSubLayerClass('mesh', MeshLayer);
return new SubLayerClass(
this.getSubLayerProps({id: 'mesh'}),
{
id: `${this.id}-mesh-${tileHeader.id}`,
tile: tileHeader,
mesh: geometry,
data: SINGLE_DATA,
getColor: getFeatureColor != null ? [255, 255, 255, 255] : _getMeshColor(tileHeader),
// Spread material into a new object so MeshLayer.updateState always calls
// updatePbrMaterialUniforms() after a geometry rebuild (binds GPU sampler to new model).
pbrMaterial: {...(material || {})},
modelMatrix,
coordinateOrigin: cartographicOrigin,
coordinateSystem,
featureIds,
_offset: 0,
},
);
}
renderLayers(): Layer | null | LayersList {
const {tileset3d, layerMap} = this.state;
if (!tileset3d) return null;
return (tileset3d.tiles as Tile3D[])
.map((tile) => {
const layerCache = (layerMap[tile.id] = layerMap[tile.id] || {tile});
let {layer} = layerCache;
if (tile.selected) {
if (!layer) {
layer = this._getSubLayer(tile);
} else if (layerCache.needsUpdate) {
layer = this._getSubLayer(tile, layer as Layer);
layerCache.needsUpdate = false;
}
}
layerCache.layer = layer;
return layer;
})
.filter(Boolean) as LayersList;
}
}
function resolveFeatureProperties(
tile: Tile3D,
featureId: number,
): Record<string, unknown> | undefined {
const content = tile.content;
if (!content) return undefined;
// 3D Tiles 1.1 — propertyTable built from EXT_structural_metadata
const propertyTable: Record<string, unknown>[] | undefined = content.propertyTable;
if (propertyTable) return propertyTable[featureId] as Record<string, unknown> | undefined;
// 3D Tiles 1.0 — batchTableJson (column-oriented arrays indexed by featureId)
const batchTableJson: Record<string, ArrayLike<unknown>> | undefined = content.batchTableJson;
if (batchTableJson) {
const properties: Record<string, unknown> = {};
for (const key of Object.keys(batchTableJson)) {
const col = batchTableJson[key];
if (Array.isArray(col) || ArrayBuffer.isView(col)) {
properties[key] = (col as any)[featureId];
}
}
return properties;
}
return undefined;
}
function buildMeshGeometry(
contentAttributes: MeshAttributes,
featureIds: ArrayLike<number> | undefined,
getFeatureColor: ((featureId: number, tile: Tile3D) => Color) | null | undefined,
tileHeader: Tile3D,
): MeshAttributes {
const attributes: MeshAttributes = {
positions: {
...contentAttributes.positions,
// Always copy into a fresh array — geometry creation may mutate the buffer
value: new Float32Array(contentAttributes.positions.value as ArrayLike<number>),
},
};
if (contentAttributes.normals) attributes.normals = contentAttributes.normals;
if (contentAttributes.texCoords) attributes.texCoords = contentAttributes.texCoords;
if (contentAttributes.uvRegions) attributes.uvRegions = contentAttributes.uvRegions;
if (getFeatureColor && featureIds && featureIds.length > 0) {
const colorBuffer = new Uint8Array(featureIds.length * 4);
for (let i = 0; i < featureIds.length; i++) {
const color = getFeatureColor(featureIds[i], tileHeader);
colorBuffer[i * 4] = color[0];
colorBuffer[i * 4 + 1] = color[1];
colorBuffer[i * 4 + 2] = color[2];
colorBuffer[i * 4 + 3] = color[3] ?? 255;
}
attributes.colors = {size: 4, value: colorBuffer, normalized: true};
} else if (getFeatureColor) {
// No featureIds — color all vertices uniformly via featureId 0
const vertexCount =
(contentAttributes.positions.value as ArrayLike<number>).length /
(contentAttributes.positions.size || 3);
const color = getFeatureColor(0, tileHeader);
const colorBuffer = new Uint8Array(vertexCount * 4);
for (let i = 0; i < vertexCount; i++) {
colorBuffer[i * 4] = color[0];
colorBuffer[i * 4 + 1] = color[1];
colorBuffer[i * 4 + 2] = color[2];
colorBuffer[i * 4 + 3] = color[3] ?? 255;
}
attributes.colors = {size: 4, value: colorBuffer, normalized: true};
} else if (contentAttributes.colors) {
attributes.colors = contentAttributes.colors;
}
return attributes;
}
const IDENTITY_4X4 = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
function isIdentityMatrix(m: ArrayLike<number>): boolean {
if (m.length !== 16) return false;
for (let i = 0; i < 16; i++) {
if (Math.abs(m[i] - IDENTITY_4X4[i]) > 1e-6) return false;
}
return true;
}

View File

@@ -1,16 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { MapComponentsProvider } from "@mapcomponents/react-maplibre";
import App from "./App";
import "./index.css";
import { DeckGlContextProvider } from "@mapcomponents/deck-gl";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<MapComponentsProvider>
<DeckGlContextProvider>
<App />
</DeckGlContextProvider>
</MapComponentsProvider>
</React.StrictMode>,
);

1
src/vite-env.d.ts vendored
View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,26 +0,0 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"noImplicitAny": false,
/* Bundler mode */
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})