Guilherme Silberfarb Costa
Lock map interaction during space movement
cfd3f5e
Raw History Blame Contribute Delete
9.3 kB
import { useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import type { Floor, Position, Space, SpaceType, Tpu } from "../../types";
import { geometryForTpu } from "./geometry";
type Props = {
floor: Floor;
spaces: Space[];
geometrySpaces: Space[];
spaceTypes: SpaceType[];
tpus: Tpu[];
showTpus: boolean;
zoom: number;
movingSpaceId: string | null;
inspectedItem: FocusedItem | null;
onInspectSpace: (space: Space) => void;
onInspectTpu: (tpu: Tpu) => void;
onClearInspection: () => void;
onEditSpace: (space: Space) => void;
onPreviewPositions: (positions: Record<string, Position>) => void;
onMoveComplete: (positions: Record<string, Position>) => void;
};
type DragState = {
start: Position;
origins: Record<string, Position>;
positions: Record<string, Position>;
moved: boolean;
};
type FocusedItem = {
kind: "space" | "tpu";
id: string;
};
type HoveredItem = FocusedItem & {
name: string;
detail: string;
x: number;
y: number;
color: string;
};
export function MapView(props: Props) {
const { floor, spaces, geometrySpaces, spaceTypes, tpus, showTpus, zoom, movingSpaceId } = props;
const svgRef = useRef<SVGSVGElement>(null);
const [drag, setDrag] = useState<DragState | null>(null);
const [hovered, setHovered] = useState<HoveredItem | null>(null);
const spacesById = useMemo(() => new Map(geometrySpaces.map(space => [space.id, space])), [geometrySpaces]);
const typeByCode = useMemo(() => new Map(spaceTypes.map(type => [type.code, type])), [spaceTypes]);
const membership = useMemo(() => {
const result = new Map<string, Tpu>();
tpus.forEach(tpu => tpu.members.forEach(member => result.set(member.space_id, tpu)));
return result;
}, [tpus]);
const focusedItem = movingSpaceId ? { kind: "space" as const, id: movingSpaceId } : hovered || props.inspectedItem;
const focusedTpuId = useMemo(() => {
if (!focusedItem) return undefined;
return focusedItem.kind === "tpu" ? focusedItem.id : membership.get(focusedItem.id)?.id;
}, [focusedItem, membership]);
const highlightedSpaceIds = useMemo(() => {
if (!focusedItem) return new Set<string>();
if (!focusedTpuId) return new Set([focusedItem.id]);
return new Set(tpus.find(tpu => tpu.id === focusedTpuId)?.members.map(member => member.space_id) || []);
}, [focusedItem, focusedTpuId, tpus]);
const hasMapFocus = Boolean(focusedItem);
function pointerPosition(event: PointerEvent): Position {
const bounds = svgRef.current!.getBoundingClientRect();
return {
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
};
}
function startDrag(event: PointerEvent<SVGGElement>, space: Space) {
if (movingSpaceId !== space.id) return;
event.stopPropagation();
props.onInspectSpace(space);
const origins = { [space.id]: { ...space.position } };
event.currentTarget.setPointerCapture(event.pointerId);
setDrag({ start: pointerPosition(event), origins, positions: origins, moved: false });
}
function moveDrag(event: PointerEvent<SVGSVGElement>) {
if (!drag) return;
const current = pointerPosition(event);
const dx = current.x - drag.start.x;
const dy = current.y - drag.start.y;
const positions = Object.fromEntries(Object.entries(drag.origins).map(([id, origin]) => [id, {
x: Math.max(0, Math.min(1, origin.x + dx)),
y: Math.max(0, Math.min(1, origin.y + dy)),
}]));
props.onPreviewPositions(positions);
setDrag({ ...drag, positions, moved: drag.moved || Math.abs(dx) + Math.abs(dy) > .001 });
}
function endDrag() {
if (!drag) return;
if (drag.moved) props.onMoveComplete(drag.positions);
setDrag(null);
}
function hoverTpu(tpu: Tpu, x: number, y: number) {
setHovered({
kind: "tpu",
id: tpu.id,
name: tpu.name,
detail: `${tpu.members.length} espaço${tpu.members.length === 1 ? "" : "s"} · clique para ver os detalhes`,
x,
y,
color: tpu.color,
});
}
return (
<div className="map-scroll">
<div className="map-shell" style={{ width: `${zoom * 100}%` }}>
<svg
ref={svgRef}
className="market-map"
viewBox={`0 0 ${floor.width} ${floor.height}`}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClick={() => { if (!movingSpaceId) props.onClearInspection(); }}
aria-label={`Planta do ${floor.name}`}
>
<image href={floor.image} width={floor.width} height={floor.height} preserveAspectRatio="none" />
{showTpus && tpus.map(tpu => {
const geometry = geometryForTpu(tpu, floor, spacesById);
if (!geometry.frames.length) return null;
return (
<g key={tpu.id} className={`tpu-visual ${focusedTpuId === tpu.id ? "highlighted" : ""} ${hasMapFocus && focusedTpuId !== tpu.id ? "dimmed" : ""}`} style={{ "--tpu-color": tpu.color } as CSSProperties}>
{geometry.links.map((link, index) => (
<g
key={`link-${index}`}
className="tpu-link-hit"
onPointerEnter={event => { if (!movingSpaceId) hoverTpu(tpu, event.clientX, event.clientY); }}
onPointerLeave={() => setHovered(null)}
onClick={event => { event.stopPropagation(); if (!movingSpaceId) props.onInspectTpu(tpu); }}
role="button"
aria-label={`Ver detalhes de ${tpu.name}`}
>
<title>Ver detalhes de {tpu.name}</title>
<line className="tpu-link-target" x1={link.x1} y1={link.y1} x2={link.x2} y2={link.y2} />
<line className={`tpu-link ${link.kind}`} x1={link.x1} y1={link.y1} x2={link.x2} y2={link.y2} />
</g>
))}
{geometry.frames.map((frame, index) => (
<g
key={`frame-${index}`}
onPointerEnter={event => { if (!movingSpaceId) hoverTpu(tpu, event.clientX, event.clientY); }}
onPointerLeave={() => setHovered(null)}
onClick={event => { event.stopPropagation(); if (!movingSpaceId) props.onInspectTpu(tpu); }}
className="tpu-frame-hit"
>
<rect className="tpu-frame" x={frame.x} y={frame.y} width={frame.width} height={frame.height} rx="10" />
{frame.label && <text className="tpu-label" x={frame.x + 6} y={frame.y - 6}>{frame.label}</text>}
</g>
))}
</g>
);
})}
{spaces.map(space => {
const x = space.position.x * floor.width;
const y = space.position.y * floor.height;
const type = typeByCode.get(space.type);
const tpu = membership.get(space.id);
const highlighted = highlightedSpaceIds.has(space.id);
return (
<g
key={space.id}
className={`space-marker ${movingSpaceId === space.id ? "movable" : ""} ${highlighted ? "highlighted" : ""} ${hasMapFocus && !highlighted ? "dimmed" : ""}`}
transform={`translate(${x} ${y})`}
onPointerEnter={event => { if (!movingSpaceId) setHovered({
kind: "space",
id: space.id,
name: space.name,
detail: `${type?.label || "Espaço"} · clique para ver os detalhes`,
x: event.clientX,
y: event.clientY,
color: type?.color || "#425466",
}); }}
onPointerLeave={() => setHovered(null)}
onPointerDown={event => startDrag(event, space)}
onClick={event => { event.stopPropagation(); if (!movingSpaceId || movingSpaceId === space.id) props.onInspectSpace(space); }}
onDoubleClick={event => { event.stopPropagation(); if (!movingSpaceId || movingSpaceId === space.id) props.onEditSpace(space); }}
role="button"
aria-label={`${space.name}, ${space.base_area_m2.toFixed(2)} metros quadrados${tpu ? `, ${tpu.name}` : ""}`}
>
<rect x="-22" y="-11" width="44" height="22" rx="6" fill={type?.color || "#fff"} stroke={tpu?.color || "#425466"} />
<text textAnchor="middle" dominantBaseline="central">{space.name.length > 12 ? `${space.name.slice(0, 11)}…` : space.name}</text>
</g>
);
})}
</svg>
{hovered && !drag && (
<div
className="map-hover-card"
style={{
"--hover-x": `${hovered.x}px`,
"--hover-y": `${hovered.y}px`,
"--hover-color": hovered.color,
} as CSSProperties}
aria-hidden="true"
>
<span>{hovered.kind === "space" ? "ESPAÇO" : "TPU"}</span>
<strong>{hovered.name}</strong>
<small>{hovered.detail}</small>
</div>
)}
</div>
</div>
);
}