Finish virtual profile

This commit is contained in:
Fuhrmann 2025-03-17 13:54:02 +01:00
parent 46db218492
commit 913af8fba6
3 changed files with 298 additions and 132 deletions

View file

@ -1,6 +1,14 @@
"use client"; "use client";
import { ChangeEvent, ReactNode, useContext, useRef, useState } from "react"; import {
ChangeEvent,
ReactNode,
forwardRef,
useContext,
useImperativeHandle,
useRef,
useState,
} from "react";
import { import {
SceneViewContext, SceneViewContext,
@ -15,7 +23,7 @@ function Toggle({
defaultChecked, defaultChecked,
}: { }: {
title: string; title: string;
onChange: (e: any) => void; onChange: (e: ChangeEvent<HTMLInputElement>) => void;
defaultChecked?: boolean; defaultChecked?: boolean;
}) { }) {
return ( return (
@ -35,29 +43,49 @@ function Toggle({
); );
} }
function Accordion({ enum Position {
children, Start,
title, Center,
}: { End,
children?: ReactNode; }
interface AccordionRef {
open: (b: boolean) => void;
}
interface AccordionProps {
title: string; title: string;
}) { position: Position;
const [expanded, setExpanded] = useState<boolean>(true); open: boolean;
children: ReactNode;
}
const Accordion = forwardRef<AccordionRef, AccordionProps>(
({ children, title, position, open }, ref) => {
const [expanded, setExpanded] = useState<boolean>(open);
const accordionBodyRef = useRef<HTMLDivElement>(null); const accordionBodyRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
open: (b: boolean) => setExpanded(b),
}));
function handleClick() { function handleClick() {
if (!accordionBodyRef.current) return; if (!accordionBodyRef.current) return;
accordionBodyRef.current.classList.toggle("hidden");
setExpanded(!expanded); setExpanded(!expanded);
} }
const className =
position === Position.Center
? "flex items-center justify-between w-full p-5 font-medium rtl:text-right text-gray-500 border border-b border-gray-200 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-800 dark:border-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 gap-3 hover:cursor-pointer"
: "flex items-center justify-between w-full p-5 font-medium rtl:text-right text-gray-500 border border-b border-gray-200 rounded-t focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-800 dark:border-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 gap-3 hover:cursor-pointer";
return ( return (
<div> <div>
<h2 id="accordion-collapse-heading-1"> <h2 id="accordion-collapse-heading-1">
<button <button
type="button" type="button"
className="flex items-center justify-between w-full p-5 font-medium rtl:text-right text-gray-500 border border-b-0 border-gray-200 rounded-t focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-800 dark:border-gray-700 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 gap-3 hover:cursor-pointer" className={className}
data-accordion-target="#accordion-collapse-body-1" data-accordion-target="#accordion-collapse-body-1"
aria-expanded={expanded ? "true" : "false"} aria-expanded={expanded ? "true" : "false"}
aria-controls="accordion-collapse-body-1" aria-controls="accordion-collapse-body-1"
@ -66,7 +94,9 @@ function Accordion({
<span>{title}</span> <span>{title}</span>
<svg <svg
data-accordion-icon data-accordion-icon
className="w-3 h-3 rotate-180 shrink-0" className={
expanded ? "w-3 h-3 shrink-0" : "w-3 h-3 rotate-180 shrink-0"
}
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@ -86,6 +116,7 @@ function Accordion({
id="accordion-collapse-body-1" id="accordion-collapse-body-1"
ref={accordionBodyRef} ref={accordionBodyRef}
aria-labelledby="accordion-collapse-heading-1" aria-labelledby="accordion-collapse-heading-1"
className={expanded ? "" : "hidden"}
> >
<div className="p-5 border border-gray-200 dark:border-gray-700 dark:bg-gray-900"> <div className="p-5 border border-gray-200 dark:border-gray-700 dark:bg-gray-900">
{children} {children}
@ -93,10 +124,14 @@ function Accordion({
</div> </div>
</div> </div>
); );
} }
);
Accordion.displayName = "Accordion";
export function Form() { export function Form() {
const svgContainerRef = useRef<HTMLDivElement>(null); const svgContainerRef = useRef<HTMLDivElement>(null);
const accordionRef1 = useRef<AccordionRef>(null);
const accordionRef0 = useRef<AccordionRef>(null);
const { sceneView } = useContext(SceneViewContext) as SceneViewContextType; const { sceneView } = useContext(SceneViewContext) as SceneViewContextType;
function handleChange() { function handleChange() {
@ -133,11 +168,10 @@ export function Form() {
if (!sceneView) return; if (!sceneView) return;
if ((e.target as HTMLInputElement).checked) { if ((e.target as HTMLInputElement).checked) {
sceneView.enableRaycaster(); // Enable raycaster with callback to handle svg element
sceneView.addEventListener("svg-created", handleSVGCreated); sceneView.enableRaycaster(handleSVGCreated);
} else { } else {
sceneView.disableRaycaster(); sceneView.disableRaycaster();
sceneView.removeEventListener("svg-created", handleSVGCreated);
} }
} }
@ -149,13 +183,20 @@ export function Form() {
svgContainerRef.current.removeChild(c); svgContainerRef.current.removeChild(c);
} }
svgContainerRef.current.appendChild(e.detail.element); svgContainerRef.current.appendChild(e.detail.element);
if (accordionRef0.current) {
accordionRef0.current.open(false);
}
if (accordionRef1.current) {
accordionRef1.current.open(true);
}
} }
return ( return (
<div className="w-full flex flex-col gap-2 overflow-y-auto"> <div className="w-full max-h-full flex flex-col gap-2">
<div className="w-full flex flex-col gap-3 p-4 border border-gray-200 rounded shadow"> <div className="w-full h-full flex flex-col gap-3 p-4 border border-gray-200 rounded shadow">
<div className="border border-gray-200 rounded grid grid-cols-2 gap-y-2 p-2">
<Toggle title="Slicing Box" onChange={handleChange} /> <Toggle title="Slicing Box" onChange={handleChange} />
<Toggle title="Drilling Profiler" onChange={handleDrilling} /> <Toggle title="Virtual Profile" onChange={handleDrilling} />
<Toggle title="Coordinate Grid" onChange={handleChangeCG} /> <Toggle title="Coordinate Grid" onChange={handleChangeCG} />
<Toggle title="Wireframe" onChange={handleChangeWireframe} /> <Toggle title="Wireframe" onChange={handleChangeWireframe} />
<Toggle <Toggle
@ -163,7 +204,14 @@ export function Form() {
onChange={handleChangeTopography} onChange={handleChangeTopography}
defaultChecked defaultChecked
/> />
<Accordion title="Layers"> </div>
<div className="overflow-y-auto">
<Accordion
title="Layers"
position={Position.Start}
open={true}
ref={accordionRef0}
>
{ {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{sceneView?.model.children.map((child) => { {sceneView?.model.children.map((child) => {
@ -203,7 +251,15 @@ export function Form() {
</div> </div>
} }
</Accordion> </Accordion>
<Accordion
title="Virtual Profile"
position={Position.Center}
open={false}
ref={accordionRef1}
>
<div ref={svgContainerRef}> </div> <div ref={svgContainerRef}> </div>
</Accordion>
</div>
</div> </div>
</div> </div>
); );

View file

@ -23,7 +23,7 @@ import { buildCoordinateGrid } from "./utils/build-coordinate-grid";
import { DragControls } from "three/examples/jsm/Addons.js"; import { DragControls } from "three/examples/jsm/Addons.js";
import { MapTilerProvider, MapView, OpenStreetMapsProvider } from "geo-three"; import { MapTilerProvider, MapView, OpenStreetMapsProvider } from "geo-three";
import { CustomMapHeightNodeShader } from "./CustomMapHeightNodeShader"; import { CustomMapHeightNodeShader } from "./CustomMapHeightNodeShader";
import { createSVG } from "./utils/create-borehole-svg"; import { Data, createSVG } from "./utils/create-borehole-svg";
export type CustomEvent = CustomEventInit<{ export type CustomEvent = CustomEventInit<{
element: SVGSVGElement | null; element: SVGSVGElement | null;
@ -41,6 +41,7 @@ export class SceneView extends EventTarget {
private _startY: number = 0; private _startY: number = 0;
private _isDragging: boolean = false; private _isDragging: boolean = false;
private static _DRAG_THRESHOLD = 5; private static _DRAG_THRESHOLD = 5;
private _callback: EventListenerOrEventListenerObject | null = null;
constructor( constructor(
scene: Scene, scene: Scene,
@ -183,21 +184,36 @@ export class SceneView extends EventTarget {
const meshes = this._model.children.filter((c) => c.name !== "Topography"); const meshes = this._model.children.filter((c) => c.name !== "Topography");
const intersects = this._raycaster.intersectObjects(meshes, true); const intersects = this._raycaster.intersectObjects(meshes, true);
// Remove existing point and add visual marker
this._removePoint();
this._addPoint(targetPosition); this._addPoint(targetPosition);
// Iterate over intersections
if (intersects.length > 0) { if (intersects.length > 0) {
const data = []; const data: Data[] = [];
for (let i = 0; i < intersects.length; i += 2) { for (let i = 0; i < intersects.length; i += 2) {
const depthStart = intersects[i].point.z; const depthStart = intersects[i].point.z;
const depthEnd = intersects[i + 1].point.z; const depthEnd = intersects[i + 1].point.z;
let name = intersects[i].object.name; const name = intersects[i].object.name;
let color = `#${( const color = `#${(
(intersects[i].object as Mesh).material as MeshStandardMaterial (intersects[i].object as Mesh).material as MeshStandardMaterial
).color.getHexString()}`; ).color.getHexString()}`;
// 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 }); 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", { const event = new CustomEvent("svg-created", {
detail: { element }, detail: { element },
}); });
@ -226,10 +242,14 @@ export class SceneView extends EventTarget {
} }
}; };
enableRaycaster() { enableRaycaster(callback: EventListenerOrEventListenerObject) {
this._container.addEventListener("pointerdown", this._pointerDownListener); this._container.addEventListener("pointerdown", this._pointerDownListener);
this._container.addEventListener("pointermove", this._pointerMoveListener); this._container.addEventListener("pointermove", this._pointerMoveListener);
this._container.addEventListener("pointerup", this._pointerUpListener); this._container.addEventListener("pointerup", this._pointerUpListener);
// Add event listener for svg-created event
this.addEventListener("svg-created", callback);
this._callback = callback;
} }
disableRaycaster() { disableRaycaster() {
@ -242,16 +262,30 @@ export class SceneView extends EventTarget {
this._pointerMoveListener this._pointerMoveListener
); );
this._container.removeEventListener("pointerup", this._pointerUpListener); 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) { private _addPoint(point: Vector3) {
const geometry = new SphereGeometry(2500, 16, 16); // Small sphere const geometry = new SphereGeometry(1000, 16, 16);
const material = new MeshBasicMaterial({ color: 0xff0000 }); // Red color const material = new MeshBasicMaterial({ color: 0xff0000 });
const sphere = new Mesh(geometry, material); const sphere = new Mesh(geometry, material);
sphere.name = "point-marker";
sphere.position.set(point.x, point.y, point.z); sphere.position.set(point.x, point.y, point.z);
this._scene.add(sphere); 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) { async function init(container: HTMLElement, modelId = MODEL_ID) {

View file

@ -2,9 +2,10 @@ import * as d3 from "d3";
import { Extent } from "./build-scene"; import { Extent } from "./build-scene";
// SVG dimensions // 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; depthStart: number;
depthEnd: number; depthEnd: number;
name: string; name: string;
@ -17,7 +18,6 @@ export function createSVG(
height: number = 800, height: number = 800,
extent: Extent extent: Extent
) { ) {
console.log(data);
const svg = d3 const svg = d3
.create("svg") .create("svg")
.attr("width", width) .attr("width", width)
@ -27,34 +27,110 @@ export function createSVG(
// Scales: Invert Y-axis so depth increases downward // Scales: Invert Y-axis so depth increases downward
const zmax = d3.max(data, (d) => d.depthStart) ?? extent.zmax; 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 const zScale = d3
.scaleLinear() .scaleLinear()
.domain([zmax, zmin]) .domain([zmax, zmin])
.range([margin.top, height - margin.bottom]); .range([margin.top, height - margin.bottom]);
// Create logical group
const barGroup = svg.append("g");
// Draw bars (formations) // Draw bars (formations)
svg barGroup
.append("g") .selectAll("rect")
.selectAll()
.data(data) .data(data)
.join("rect") .join("rect")
.attr("x", margin.left) .attr("x", margin.left)
.attr("y", (d) => zScale(d.depthStart)) .attr("y", (d) => zScale(d.depthStart))
.attr("height", (d) => zScale(d.depthEnd) - 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); .attr("fill", (d) => d.color);
// Add labels (formation names) // Add labels (formation names)
svg barGroup
.selectAll(".label") .selectAll("text")
.data(data) .data(data)
.enter() .join("text")
.append("text") .attr("x", margin.left + barWidth + 5)
.attr("class", "label") .attr("y", (d) => (zScale(d.depthStart) + zScale(d.depthEnd)) / 2)
.attr("x", width - margin.right + 5) // Place text slightly outside the bar .attr("text-anchor", "start")
.attr("y", (d) => (zScale(d.depthStart) + zScale(d.depthEnd)) / 2) // Center in the bar .attr("fill", "black")
.text((d) => d.name); .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(); 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;
}