From 913af8fba6b1c54a31bab11a3efc59f690ca16eb Mon Sep 17 00:00:00 2001 From: Thomas Fuhrmann Date: Mon, 17 Mar 2025 13:54:02 +0100 Subject: [PATCH] Finish virtual profile --- app/components/Form.tsx | 270 +++++++++++++++---------- app/three/SceneView.ts | 52 ++++- app/three/utils/create-borehole-svg.ts | 108 ++++++++-- 3 files changed, 298 insertions(+), 132 deletions(-) diff --git a/app/components/Form.tsx b/app/components/Form.tsx index b8a007c..afccef3 100644 --- a/app/components/Form.tsx +++ b/app/components/Form.tsx @@ -1,6 +1,14 @@ "use client"; -import { ChangeEvent, ReactNode, useContext, useRef, useState } from "react"; +import { + ChangeEvent, + ReactNode, + forwardRef, + useContext, + useImperativeHandle, + useRef, + useState, +} from "react"; import { SceneViewContext, @@ -15,7 +23,7 @@ function Toggle({ defaultChecked, }: { title: string; - onChange: (e: any) => void; + onChange: (e: ChangeEvent) => void; defaultChecked?: boolean; }) { return ( @@ -35,68 +43,95 @@ function Toggle({ ); } -function Accordion({ - children, - title, -}: { - children?: ReactNode; +enum Position { + Start, + Center, + End, +} + +interface AccordionRef { + open: (b: boolean) => void; +} + +interface AccordionProps { title: string; -}) { - const [expanded, setExpanded] = useState(true); - const accordionBodyRef = useRef(null); + position: Position; + open: boolean; + children: ReactNode; +} - function handleClick() { - if (!accordionBodyRef.current) return; +const Accordion = forwardRef( + ({ children, title, position, open }, ref) => { + const [expanded, setExpanded] = useState(open); + const accordionBodyRef = useRef(null); - accordionBodyRef.current.classList.toggle("hidden"); - setExpanded(!expanded); - } + useImperativeHandle(ref, () => ({ + open: (b: boolean) => setExpanded(b), + })); - return ( -
-

- -

-
-
- {children} + {title} + + + +
+
+ {children} +
-
- ); -} + ); + } +); +Accordion.displayName = "Accordion"; export function Form() { const svgContainerRef = useRef(null); + const accordionRef1 = useRef(null); + const accordionRef0 = useRef(null); const { sceneView } = useContext(SceneViewContext) as SceneViewContextType; function handleChange() { @@ -133,11 +168,10 @@ export function Form() { if (!sceneView) return; if ((e.target as HTMLInputElement).checked) { - sceneView.enableRaycaster(); - sceneView.addEventListener("svg-created", handleSVGCreated); + // Enable raycaster with callback to handle svg element + sceneView.enableRaycaster(handleSVGCreated); } else { sceneView.disableRaycaster(); - sceneView.removeEventListener("svg-created", handleSVGCreated); } } @@ -149,61 +183,83 @@ export function Form() { svgContainerRef.current.removeChild(c); } svgContainerRef.current.appendChild(e.detail.element); + if (accordionRef0.current) { + accordionRef0.current.open(false); + } + if (accordionRef1.current) { + accordionRef1.current.open(true); + } } return ( -
-
- - - - - - - { -
- {sceneView?.model.children.map((child) => { - const key = `toggle-visibility-${child.name}`; - const color = `#${( - (child as Mesh).material as MeshStandardMaterial - ).color.getHexString()}`; - const visible = (child as Mesh).visible; +
+
+
+ + + + + +
+
+ + { +
+ {sceneView?.model.children.map((child) => { + const key = `toggle-visibility-${child.name}`; + const color = `#${( + (child as Mesh).material as MeshStandardMaterial + ).color.getHexString()}`; + const visible = (child as Mesh).visible; - return ( -
- - handleCheckboxChange(child.name)} - className="hover:cursor-pointer" - defaultChecked={visible ? true : false} - /> - -
- ); - })} -
- } -
-
+ + handleCheckboxChange(child.name)} + className="hover:cursor-pointer" + defaultChecked={visible ? true : false} + /> + +
+ ); + })} +
+ } + + +
+
+
); diff --git a/app/three/SceneView.ts b/app/three/SceneView.ts index f24f401..66fe55f 100644 --- a/app/three/SceneView.ts +++ b/app/three/SceneView.ts @@ -23,7 +23,7 @@ import { buildCoordinateGrid } from "./utils/build-coordinate-grid"; import { DragControls } from "three/examples/jsm/Addons.js"; import { MapTilerProvider, MapView, OpenStreetMapsProvider } from "geo-three"; import { CustomMapHeightNodeShader } from "./CustomMapHeightNodeShader"; -import { createSVG } from "./utils/create-borehole-svg"; +import { Data, createSVG } from "./utils/create-borehole-svg"; export type CustomEvent = CustomEventInit<{ element: SVGSVGElement | null; @@ -41,6 +41,7 @@ export class SceneView extends EventTarget { private _startY: number = 0; private _isDragging: boolean = false; private static _DRAG_THRESHOLD = 5; + private _callback: EventListenerOrEventListenerObject | null = null; constructor( scene: Scene, @@ -183,21 +184,36 @@ export class SceneView extends EventTarget { const meshes = this._model.children.filter((c) => c.name !== "Topography"); const intersects = this._raycaster.intersectObjects(meshes, true); + // Remove existing point and add visual marker + this._removePoint(); this._addPoint(targetPosition); + + // Iterate over intersections if (intersects.length > 0) { - const data = []; + const data: Data[] = []; for (let i = 0; i < intersects.length; i += 2) { const depthStart = intersects[i].point.z; const depthEnd = intersects[i + 1].point.z; - let name = intersects[i].object.name; - let color = `#${( + const name = intersects[i].object.name; + const color = `#${( (intersects[i].object as Mesh).material as MeshStandardMaterial ).color.getHexString()}`; - data.push({ depthStart, depthEnd, name, color }); + // Avoid duplicate entries, just update the depth information + const index = data.findIndex((d) => d.name === name); + if (index > -1) { + data[index] = { + depthStart: data[index].depthStart, + depthEnd, + name, + color, + }; + } else { + data.push({ depthStart, depthEnd, name, color }); + } } - const element = createSVG(data, 400, 800, this._extent); + const element = createSVG(data, 400, 600, this._extent); const event = new CustomEvent("svg-created", { detail: { element }, }); @@ -226,10 +242,14 @@ export class SceneView extends EventTarget { } }; - enableRaycaster() { + enableRaycaster(callback: EventListenerOrEventListenerObject) { this._container.addEventListener("pointerdown", this._pointerDownListener); this._container.addEventListener("pointermove", this._pointerMoveListener); this._container.addEventListener("pointerup", this._pointerUpListener); + + // Add event listener for svg-created event + this.addEventListener("svg-created", callback); + this._callback = callback; } disableRaycaster() { @@ -242,16 +262,30 @@ export class SceneView extends EventTarget { this._pointerMoveListener ); this._container.removeEventListener("pointerup", this._pointerUpListener); + + if (this._callback) { + this.removeEventListener("svg-created", this._callback); + } } + // Add point marker for bore profiles private _addPoint(point: Vector3) { - const geometry = new SphereGeometry(2500, 16, 16); // Small sphere - const material = new MeshBasicMaterial({ color: 0xff0000 }); // Red color + const geometry = new SphereGeometry(1000, 16, 16); + const material = new MeshBasicMaterial({ color: 0xff0000 }); const sphere = new Mesh(geometry, material); + sphere.name = "point-marker"; sphere.position.set(point.x, point.y, point.z); this._scene.add(sphere); } + + private _removePoint() { + const o = this._scene.getObjectByName("point-marker"); + + if (o) { + this._scene.remove(o); + } + } } async function init(container: HTMLElement, modelId = MODEL_ID) { diff --git a/app/three/utils/create-borehole-svg.ts b/app/three/utils/create-borehole-svg.ts index 7d27cb1..5facd15 100644 --- a/app/three/utils/create-borehole-svg.ts +++ b/app/three/utils/create-borehole-svg.ts @@ -2,9 +2,10 @@ import * as d3 from "d3"; import { Extent } from "./build-scene"; // SVG dimensions -const margin = { top: 20, right: 250, bottom: 20, left: 20 }; +const margin = { top: 20, right: 20, bottom: 20, left: 80 }; +const barWidth = 30; -interface Data { +export interface Data { depthStart: number; depthEnd: number; name: string; @@ -17,7 +18,6 @@ export function createSVG( height: number = 800, extent: Extent ) { - console.log(data); const svg = d3 .create("svg") .attr("width", width) @@ -27,34 +27,110 @@ export function createSVG( // Scales: Invert Y-axis so depth increases downward const zmax = d3.max(data, (d) => d.depthStart) ?? extent.zmax; - const zmin = d3.max(data, (d) => d.depthEnd) ?? extent.zmin; + const zmin = d3.min(data, (d) => d.depthEnd) ?? extent.zmin; const zScale = d3 .scaleLinear() .domain([zmax, zmin]) .range([margin.top, height - margin.bottom]); + // Create logical group + const barGroup = svg.append("g"); + // Draw bars (formations) - svg - .append("g") - .selectAll() + barGroup + .selectAll("rect") .data(data) .join("rect") .attr("x", margin.left) .attr("y", (d) => zScale(d.depthStart)) .attr("height", (d) => zScale(d.depthEnd) - zScale(d.depthStart)) - .attr("width", width - margin.left - margin.right) + .attr("width", barWidth) .attr("fill", (d) => d.color); // Add labels (formation names) - svg - .selectAll(".label") + barGroup + .selectAll("text") .data(data) - .enter() - .append("text") - .attr("class", "label") - .attr("x", width - margin.right + 5) // Place text slightly outside the bar - .attr("y", (d) => (zScale(d.depthStart) + zScale(d.depthEnd)) / 2) // Center in the bar - .text((d) => d.name); + .join("text") + .attr("x", margin.left + barWidth + 5) + .attr("y", (d) => (zScale(d.depthStart) + zScale(d.depthEnd)) / 2) + .attr("text-anchor", "start") + .attr("fill", "black") + .style("font-size", "12px") + .each(function (d) { + const textElement = d3.select(this); + textElement.selectAll("tspan").remove(); // Clear previous tspans + + const groups = groupWordsByFour(d.name); + let dy = 0; + + for (const group of groups) { + textElement + .append("tspan") + .attr("x", margin.left + barWidth + 5) + .attr("dy", dy) + .text(group.join(" ")); + + dy = 14; + } + }); + + // Add depth labels + svg + .append("g") + .selectAll("text") + .data(data) + .join("text") + .attr("x", margin.left - 5) + .attr("y", (d) => + d.depthStart - d.depthEnd < 100 + ? zScale(d.depthStart) - 10 + : zScale(d.depthStart) + ) + .attr("dy", "0.35em") + .attr("text-anchor", "end") + .attr("fill", "black") + .style("font-size", "12px") + .text((d) => `${d.depthStart.toFixed(0)}m`); + + // Add label for last depth + svg + .append("g") + .selectAll("text") + .data(data) + .join("text") + .attr("x", margin.left - 5) + .attr("y", (d, i) => (i === data.length - 1 ? zScale(d.depthEnd) : null)) + .attr("dy", "0.35em") + .attr("text-anchor", "end") + .attr("fill", "black") + .style("font-size", "12px") + .text((d, i) => (i === data.length - 1 ? `${d.depthEnd.toFixed(0)}m` : "")); return svg.node(); } + +// Group words to split lines if necessary +function groupWordsByFour(inputString: string) { + const words = inputString.split(" "); + + // Use reduce to group the words into chunks of four + const groups = words.reduce( + (result: string[][], word: string, index: number) => { + const groupIndex = Math.floor(index / 4); + + // If the group doesn't exist yet, create an empty array for it + if (!result[groupIndex]) { + result[groupIndex] = []; + } + + // Add the current word to the correct group + result[groupIndex].push(word); + + return result; + }, + [] + ); + + return groups; +}