diff --git a/editor-app/components/ActivityDiagram.tsx b/editor-app/components/ActivityDiagram.tsx index facfc10..f1bb5fb 100644 --- a/editor-app/components/ActivityDiagram.tsx +++ b/editor-app/components/ActivityDiagram.tsx @@ -1,7 +1,5 @@ -import { useState, useEffect, MutableRefObject, JSX } from "react"; +import { useState, useEffect, MutableRefObject, JSX, useRef } from "react"; import Breadcrumb from "react-bootstrap/Breadcrumb"; -import Row from "react-bootstrap/Row"; -import Col from "react-bootstrap/Col"; import { drawActivityDiagram } from "@/diagram/DrawActivityDiagram"; import { ConfigData } from "@/diagram/config"; import { Model } from "@/lib/Model"; @@ -19,6 +17,9 @@ interface Props { rightClickActivity: (a: Activity) => void; rightClickParticipation: (a: Activity, p: Participation) => void; svgRef: MutableRefObject; + hideNonParticipating: boolean; + sortedIndividuals?: Individual[]; + highlightedActivityId?: string | null; } const ActivityDiagram = (props: Props) => { @@ -34,6 +35,9 @@ const ActivityDiagram = (props: Props) => { rightClickActivity, rightClickParticipation, svgRef, + hideNonParticipating, + sortedIndividuals, + highlightedActivityId, } = props; const [plot, setPlot] = useState({ @@ -41,6 +45,49 @@ const ActivityDiagram = (props: Props) => { height: 0, }); + const scrollContainerRef = useRef(null); + const wrapperRef = useRef(null); // NEW: Ref for the outer wrapper + const [wrapperHeight, setWrapperHeight] = useState(0); // NEW: State for wrapper height + const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 }); + + // Track scroll position for axis positioning + const handleScroll = () => { + if (scrollContainerRef.current) { + setScrollPosition({ + x: scrollContainerRef.current.scrollLeft, + y: scrollContainerRef.current.scrollTop, + }); + } + }; + + useEffect(() => { + const container = scrollContainerRef.current; + if (container) { + container.addEventListener("scroll", handleScroll); + return () => container.removeEventListener("scroll", handleScroll); + } + }, []); + + // NEW: Measure wrapper height to draw axis correctly + useEffect(() => { + if (!wrapperRef.current) return; + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setWrapperHeight(entry.contentRect.height); + } + }); + resizeObserver.observe(wrapperRef.current); + return () => resizeObserver.disconnect(); + }, []); + + useEffect(() => { + const container = scrollContainerRef.current; + if (container) { + container.addEventListener("scroll", handleScroll); + return () => container.removeEventListener("scroll", handleScroll); + } + }, []); + useEffect(() => { setPlot( drawActivityDiagram( @@ -53,9 +100,96 @@ const ActivityDiagram = (props: Props) => { clickParticipation, rightClickIndividual, rightClickActivity, - rightClickParticipation + rightClickParticipation, + hideNonParticipating, + sortedIndividuals ) ); + + // Apply highlighting logic to participation rects (the actual visible colored blocks) + const svg = svgRef.current; + if (svg) { + // Target participation-rect elements (the visible colored blocks) + const allParticipationRects = svg.querySelectorAll(".participation-rect"); + + if (highlightedActivityId) { + // Dim all participation rects + allParticipationRects.forEach((el: SVGElement) => { + el.style.opacity = "0.15"; + el.style.stroke = ""; + el.style.strokeWidth = ""; + }); + + // Track bounding box of all highlighted rects + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + let foundHighlighted = false; + + // Highlight participation rects belonging to the selected activity + // Participation rect IDs are in format: p_{activityId}_{individualId}_{segStart}_{segEnd} + allParticipationRects.forEach((el: SVGElement) => { + const elId = el.getAttribute("id") || ""; + + // Check if this participation rect belongs to the highlighted activity + // ID format: p_{activityId}_{rest...} + if (elId.startsWith("p_" + highlightedActivityId + "_")) { + el.style.opacity = "1"; + el.style.stroke = "#000"; + el.style.strokeWidth = "2px"; + // Bring to front + el.parentNode?.appendChild(el); + + // Calculate bounding box from this element + const rect = el as SVGGraphicsElement; + const bbox = rect.getBBox?.(); + if (bbox) { + foundHighlighted = true; + minX = Math.min(minX, bbox.x); + minY = Math.min(minY, bbox.y); + maxX = Math.max(maxX, bbox.x + bbox.width); + maxY = Math.max(maxY, bbox.y + bbox.height); + } + } + }); + + // Remove any existing highlight borders first + svg + .querySelectorAll(".highlight-border") + .forEach((el: Element) => el.remove()); + + // Draw a dashed border around the calculated bounding box + if (foundHighlighted && minX < Infinity) { + const ns = "http://www.w3.org/2000/svg"; + const highlightRect = document.createElementNS(ns, "rect"); + highlightRect.setAttribute("class", "highlight-border"); + highlightRect.setAttribute("x", String(minX - 3)); + highlightRect.setAttribute("y", String(minY - 3)); + highlightRect.setAttribute("width", String(maxX - minX + 6)); + highlightRect.setAttribute("height", String(maxY - minY + 6)); + highlightRect.setAttribute("fill", "none"); + highlightRect.setAttribute("stroke", "#000000"); + highlightRect.setAttribute("stroke-width", "2"); + highlightRect.setAttribute("stroke-dasharray", "6,3"); + highlightRect.setAttribute("rx", "6"); + highlightRect.setAttribute("pointer-events", "none"); + svg.appendChild(highlightRect); + } + } else { + // Reset styles if nothing highlighted + allParticipationRects.forEach((el: SVGElement) => { + el.style.opacity = ""; + el.style.stroke = ""; + el.style.strokeWidth = ""; + }); + + // Remove any highlight borders + svg + .querySelectorAll(".highlight-border") + .forEach((el: Element) => el.remove()); + } + } }, [ dataset, configData, @@ -67,6 +201,9 @@ const ActivityDiagram = (props: Props) => { rightClickIndividual, rightClickActivity, rightClickParticipation, + hideNonParticipating, + sortedIndividuals, + highlightedActivityId, ]); const buildCrumbs = () => { @@ -78,27 +215,202 @@ const ActivityDiagram = (props: Props) => { const text = act ? act.name : {dataset.name ?? "Top"}; context.push( setActivityContext(link) }} key={id ?? "."} - >{text}); - if (id == undefined) - break; + > + {text} + + ); + if (id == undefined) break; id = act!.partOf; } return context.reverse(); }; const crumbs: JSX.Element[] = buildCrumbs(); + // Axis configuration + const axisMargin = configData.presentation.axis.margin; + const axisWidth = configData.presentation.axis.width; + const axisColour = configData.presentation.axis.colour; + const axisEndMargin = configData.presentation.axis.endMargin; + + // Calculate visible dimensions + // Use measured wrapper height, fallback to calculation if 0 (initial render) + // FIX: Check if window is defined before accessing innerHeight + const containerHeight = + wrapperHeight || + Math.min( + plot.height, + (typeof window !== "undefined" ? window.innerHeight : 800) - 250 + ); + const bottomAxisHeight = axisMargin + 30; + return ( <> {crumbs} -
- + {/* Fixed Y-Axis (Space) - left side */} +
+ + {/* Y-Axis arrow */} + + + + + + + {/* Y-Axis label "Space" */} + + Space + + +
+ + {/* Fixed X-Axis (Time) - bottom */} +
+ + {/* X-Axis arrow */} + + + + + + + {/* X-Axis label "Time" */} + + Time + + +
+ + {/* Corner piece to cover overlap */} +
+ + {/* Scrollable diagram content */} +
+ +
); diff --git a/editor-app/components/ActivityDiagramWrap.tsx b/editor-app/components/ActivityDiagramWrap.tsx index df3514a..16222f0 100644 --- a/editor-app/components/ActivityDiagramWrap.tsx +++ b/editor-app/components/ActivityDiagramWrap.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, Dispatch } from "react"; +import { useEffect, useState, useRef, Dispatch, useMemo } from "react"; import { config } from "@/diagram/config"; import SetIndividual from "@/components/SetIndividual"; import SetActivity from "@/components/SetActivity"; @@ -12,9 +12,25 @@ import SortIndividuals from "./SortIndividuals"; import SetParticipation from "./SetParticipation"; import Undo from "./Undo"; import { Model } from "@/lib/Model"; -import { Activity, Id, Individual, Maybe, Participation } from "@/lib/Schema"; +import { + Activity, + Id, + Individual, + Maybe, + Participation, + EntityType, +} from "@/lib/Schema"; import ExportJson from "./ExportJson"; import ExportSvg from "./ExportSvg"; +import { Button } from "react-bootstrap"; +import HideIndividuals from "./HideIndividuals"; +import React from "react"; +import Card from "react-bootstrap/Card"; +import DiagramLegend from "./DiagramLegend"; +import EditInstalledComponent from "./EditInstalledComponent"; +import EditSystemComponentInstallation from "./EditSystemComponentInstallation"; +import EntityTypeLegend from "./EntityTypeLegend"; +import { load, save } from "@/lib/ActivityLib"; const beforeUnloadHandler = (ev: BeforeUnloadEvent) => { ev.returnValue = ""; @@ -22,11 +38,90 @@ const beforeUnloadHandler = (ev: BeforeUnloadEvent) => { return; }; -/* XXX Most of this component needs refactoring into a Controller class, - * leaving the react component as just the View. */ +/** + * Filter individuals based on compact mode rules: + * 1. Top-level SC/IC with installations are ALWAYS hidden in compact mode + * 2. Virtual rows are shown only if they participate + * 3. Systems are shown if they or their children participate + * 4. Regular individuals are shown if they participate + */ +function filterIndividualsForCompactMode( + individuals: Individual[], + participatingIds: Set, + dataset: Model +): Individual[] { + // Track which Systems should be visible (because their children participate) + const parentSystemsToShow = new Set(); + + // First pass: find parent systems of participating virtual rows + participatingIds.forEach((id) => { + if (id.includes("__installed_in__")) { + const parts = id.split("__installed_in__"); + const rest = parts[1]; + const targetId = rest.split("__")[0]; + + const target = dataset.individuals.get(targetId); + if (target) { + const targetType = target.entityType ?? EntityType.Individual; + + if (targetType === EntityType.System) { + parentSystemsToShow.add(targetId); + } else if (targetType === EntityType.SystemComponent) { + // Find parent System of this SC + if (target.installations) { + target.installations.forEach((inst) => { + const parentTarget = dataset.individuals.get(inst.targetId); + if (parentTarget) { + const parentType = + parentTarget.entityType ?? EntityType.Individual; + if (parentType === EntityType.System) { + parentSystemsToShow.add(inst.targetId); + } + } + }); + } + } + } + } + }); + + return individuals.filter((ind) => { + const entityType = ind.entityType ?? EntityType.Individual; + const isVirtualRow = ind.id.includes("__installed_in__"); + + // Rule 1: Top-level SC/IC with installations - ALWAYS hidden + if ( + (entityType === EntityType.SystemComponent || + entityType === EntityType.InstalledComponent) && + !isVirtualRow && + ind.installations && + ind.installations.length > 0 + ) { + return false; + } + + // Rule 2: Virtual rows - show only if participating + if (isVirtualRow) { + return participatingIds.has(ind.id); + } + + // Rule 3: Systems - show if participating or if children participate + if (entityType === EntityType.System) { + return participatingIds.has(ind.id) || parentSystemsToShow.has(ind.id); + } + + // Rule 4: Regular individuals (and SC/IC without installations) - show if participating + return participatingIds.has(ind.id); + }); +} + export default function ActivityDiagramWrap() { + // compactMode hides individuals that participate in zero activities + const [compactMode, setCompactMode] = useState(false); const model = new Model(); const [dataset, setDataset] = useState(model); + // Add a state to track initialization + const [isInitialized, setIsInitialized] = useState(false); const [dirty, setDirty] = useState(false); const [activityContext, setActivityContext] = useState>(undefined); const [undoHistory, setUndoHistory] = useState([]); @@ -46,13 +141,86 @@ export default function ActivityDiagramWrap() { const [showConfigModal, setShowConfigModal] = useState(false); const [showSortIndividuals, setShowSortIndividuals] = useState(false); + // NEW: State for highlighting activity from legend + const [highlightedActivityId, setHighlightedActivityId] = useState< + string | null + >(null); + + // State for the InstalledComponent editor + const [showInstalledComponentEditor, setShowInstalledComponentEditor] = + useState(false); + const [selectedInstalledComponent, setSelectedInstalledComponent] = useState< + Individual | undefined + >(undefined); + const [targetSlotId, setTargetSlotId] = useState( + undefined + ); + + // State for the SystemComponent editor + const [showSystemComponentEditor, setShowSystemComponentEditor] = + useState(false); + const [selectedSystemComponent, setSelectedSystemComponent] = useState< + Individual | undefined + >(undefined); + const [targetSystemId, setTargetSystemId] = useState( + undefined + ); + + const handleOpenSystemComponentInstallation = (individual: Individual) => { + setSelectedSystemComponent(individual); + setTargetSystemId(undefined); + setShowSystemComponentEditor(true); + }; + + const handleOpenInstalledComponentInstallation = (individual: Individual) => { + setSelectedInstalledComponent(individual); + setTargetSlotId(undefined); + setTargetSystemId(undefined); + setShowInstalledComponentEditor(true); + }; + useEffect(() => { - if (dirty) - window.addEventListener("beforeunload", beforeUnloadHandler); - else - window.removeEventListener("beforeunload", beforeUnloadHandler); + if (dirty) window.addEventListener("beforeunload", beforeUnloadHandler); + else window.removeEventListener("beforeunload", beforeUnloadHandler); }, [dirty]); + // 1. Load diagram from localStorage on mount + useEffect(() => { + // Ensure we are in the browser + if (typeof window !== "undefined") { + const savedTtl = localStorage.getItem("4d-activity-editor-autosave"); + if (savedTtl) { + try { + const loadedModel = load(savedTtl); + if (loadedModel instanceof Model) { + setDataset(loadedModel); + setUndoHistory([]); // Clear undo history on load + } + } catch (err) { + console.error("Failed to load autosave:", err); + } + } + setIsInitialized(true); + } + }, []); + + // 2. Save diagram to localStorage whenever dataset changes + useEffect(() => { + if (!isInitialized) return; + + // Debounce save to avoid performance hit on every small change + const timer = setTimeout(() => { + try { + const ttl = save(dataset); + localStorage.setItem("4d-activity-editor-autosave", ttl); + } catch (err) { + console.error("Failed to autosave:", err); + } + }, 1000); + + return () => clearTimeout(timer); + }, [dataset, isInitialized]); + const updateDataset = (updater: Dispatch) => { setUndoHistory([dataset, ...undoHistory.slice(0, 5)]); const d = dataset.clone(); @@ -60,7 +228,6 @@ export default function ActivityDiagramWrap() { setDataset(d); setDirty(true); }; - /* Callers of this function must also handle the dirty flag. */ const replaceDataset = (d: Model) => { setUndoHistory([]); setActivityContext(undefined); @@ -75,22 +242,65 @@ export default function ActivityDiagramWrap() { const svgRef = useRef(null); + const individualsArray = Array.from(dataset.individuals.values()); + const deleteIndividual = (id: string) => { updateDataset((d: Model) => d.removeIndividual(id)); }; const setIndividual = (individual: Individual) => { updateDataset((d: Model) => d.addIndividual(individual)); }; - const deleteActivity = (id: string) => { - updateDataset((d: Model) => d.removeActivity(id)); - }; const setActivity = (activity: Activity) => { updateDataset((d: Model) => d.addActivity(activity)); }; - const clickIndividual = (i: Individual) => { - setSelectedIndividual(i); - setShowIndividual(true); + const clickIndividual = (i: any) => { + const isVirtual = i.id.includes("__installed_in__"); + + if (isVirtual) { + const originalId = i.id.split("__installed_in__")[0]; + const rest = i.id.split("__installed_in__")[1]; + const parts = rest.split("__"); + const targetId = parts[0]; + + const originalIndividual = dataset.individuals.get(originalId); + if (!originalIndividual) return; + + const entityType = originalIndividual.entityType ?? EntityType.Individual; + + if (entityType === EntityType.SystemComponent) { + setSelectedSystemComponent(originalIndividual); + setTargetSystemId(targetId); + setShowSystemComponentEditor(true); + } else if (entityType === EntityType.InstalledComponent) { + const contextMatch = i.id.match(/__ctx_([^_]+)$/); + const contextId = contextMatch ? contextMatch[1] : undefined; + + let systemId: string | undefined; + if (contextId && targetId) { + const targetSc = dataset.individuals.get(targetId); + if (targetSc?.installations) { + const scInst = targetSc.installations.find( + (inst) => inst.id === contextId + ); + if (scInst) { + systemId = scInst.targetId; + } + } + } + + setSelectedInstalledComponent(originalIndividual); + setTargetSlotId(targetId); + setTargetSystemId(systemId); + setShowInstalledComponentEditor(true); + } + } else { + const individual = dataset.individuals.get(i.id); + if (!individual) return; + + setSelectedIndividual(individual); + setShowIndividual(true); + } }; const clickActivity = (a: Activity) => { setSelectedActivity(a); @@ -114,30 +324,96 @@ export default function ActivityDiagramWrap() { ); }; - const individualsArray: Individual[] = []; - dataset.individuals.forEach((i: Individual) => individualsArray.push(i)); + const activitiesArray = Array.from(dataset.activities.values()); + + let activitiesInView: Activity[] = []; + if (activityContext) { + activitiesInView = activitiesArray.filter( + (a) => a.partOf === activityContext + ); + } else { + activitiesInView = activitiesArray.filter((a) => !a.partOf); + } + + // Get all participating IDs for current view + const participatingIds = useMemo(() => { + const ids = new Set(); + activitiesInView.forEach((a) => + a.participations.forEach((p) => ids.add(p.individualId)) + ); + return ids; + }, [activitiesInView]); + + // Get sorted individuals and apply compact mode filter + const sortedIndividuals = useMemo(() => { + const allIndividuals = dataset.getDisplayIndividuals(); + + if (compactMode) { + return filterIndividualsForCompactMode( + allIndividuals, + participatingIds, + dataset + ); + } + + return allIndividuals; + }, [dataset, compactMode, participatingIds]); - const activitiesArray: Activity[] = []; - dataset.activities.forEach((a: Activity) => activitiesArray.push(a)); + const partsCountMap: Record = {}; + activitiesInView.forEach((a) => { + partsCountMap[a.id] = + typeof dataset.getPartsCount === "function" + ? dataset.getPartsCount(a.id) + : 0; + }); + // render return ( <> - - - + + + {/* Entity Type Legend above Activity Legend */} + + { + setSelectedActivity(a); + setShowActivity(true); + }} + highlightedActivityId={highlightedActivityId} + onHighlightActivity={setHighlightedActivityId} + /> + + + + + + + {/* All buttons in a flex container that wraps */} +
+ {/* Left side buttons */} +
- - - +
+ + {/* Center - Load/Save TTL */} +
+ +
+ + {/* Right side buttons */} +
+ 0} undo={undo} - clearDiagram={clearDiagram}/> + clearDiagram={clearDiagram} + /> - - - - - - - - - + + {/* */} +
+
+ + + + ); } diff --git a/editor-app/components/DiagramLegend.tsx b/editor-app/components/DiagramLegend.tsx new file mode 100644 index 0000000..1a108cf --- /dev/null +++ b/editor-app/components/DiagramLegend.tsx @@ -0,0 +1,189 @@ +import React, { useState } from "react"; +import Card from "react-bootstrap/Card"; +import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; +import OverlayTrigger from "react-bootstrap/OverlayTrigger"; +import Tooltip from "react-bootstrap/Tooltip"; +import { Activity } from "@/lib/Schema"; +import { ArrowUp } from "../components/svg/ArrowUp"; + +interface Props { + activities: Activity[]; + activityColors: string[]; + partsCount?: Record; + onOpenActivity?: (a: Activity) => void; + highlightedActivityId?: string | null; + onHighlightActivity?: (id: string | null) => void; +} + +const DiagramLegend = ({ + activities, + activityColors, + partsCount, + onOpenActivity, + highlightedActivityId, + onHighlightActivity, +}: Props) => { + const [hovered, setHovered] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + + // Filter activities based on search term + const filteredActivities = activities.filter((activity) => + activity.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // Decide threshold based on screen size: + // - big screens: 12 + // - smaller screens (laptops/tablets): 8 + const [scrollThreshold, setScrollThreshold] = useState(12); + + React.useEffect(() => { + const updateThreshold = () => { + const width = window.innerWidth; + const height = window.innerHeight; + + // "Laptop-ish" widths or limited height → smaller threshold + if (width <= 1400 || height <= 800) { + setScrollThreshold(8); + } else { + setScrollThreshold(12); + } + }; + + updateThreshold(); + window.addEventListener("resize", updateThreshold); + return () => window.removeEventListener("resize", updateThreshold); + }, []); + + // Only show scrollbar when we exceed the threshold + const needsScroll = filteredActivities.length > scrollThreshold; + + return ( + + + + Activity Legend{" "} + + (click for diagram highlight) + + + + {/* Search input - only show if there are more than 5 activities */} + {activities.length > 5 && ( + setSearchTerm(e.target.value)} + className="mb-2" + style={{ fontSize: "0.8rem" }} + /> + )} + + {/* Scrollable container - only scroll when needed */} +
+ {filteredActivities.length === 0 && searchTerm ? ( +
No activities found
+ ) : ( + filteredActivities.map((activity) => { + const originalIdx = activities.findIndex( + (a) => a.id === activity.id + ); + const count = partsCount ? partsCount[activity.id] ?? 0 : 0; + const isHighlighted = highlightedActivityId === activity.id; + + return ( +
+ onHighlightActivity && + onHighlightActivity(isHighlighted ? null : activity.id) + } + title="Click to highlight in diagram" + > +
+ + + {count > 0 ? ( + <> + {activity.name}{" "} + + ({count} subtask{count !== 1 ? "s" : ""}) + + + ) : ( + activity.name + )} + +
+ +
+ {onOpenActivity && ( + + Open activity editor + + } + > + + + )} +
+
+ ); + }) + )} +
+ + {/* No Activity item - outside scrollable area */} +
+ + No Activity +
+
+
+ ); +}; + +export default DiagramLegend; diff --git a/editor-app/components/DiagramPersistence.tsx b/editor-app/components/DiagramPersistence.tsx index d7bc1da..c9368c0 100644 --- a/editor-app/components/DiagramPersistence.tsx +++ b/editor-app/components/DiagramPersistence.tsx @@ -1,9 +1,7 @@ import { useEffect, useState } from "react"; -import { Button, Container, Form } from "react-bootstrap"; -import Dropdown from 'react-bootstrap/Dropdown'; -import DropdownButton from 'react-bootstrap/DropdownButton'; -import Row from "react-bootstrap/Row"; -import Col from "react-bootstrap/Col"; +import { Button, Form } from "react-bootstrap"; +import Dropdown from "react-bootstrap/Dropdown"; +import DropdownButton from "react-bootstrap/DropdownButton"; import { save, @@ -27,25 +25,26 @@ const DiagramPersistence = (props: any) => { useEffect(() => { fetch("examples/index.json") - .then(res => { + .then((res) => { if (!res.ok) { console.log(`Fetching examples index failed: ${res.status}`); return; } return res.json(); }) - .then(json => { + .then((json) => { setExamples(json); }); }, []); function downloadTtl() { if (refDataOnly) { - saveFile(saveRefDataAsTTL(dataset), + saveFile( + saveRefDataAsTTL(dataset), dataset.filename.replace(/(\.[^.]*)?$/, "_ref_data$&"), - "text/turtle"); - } - else { + "text/turtle" + ); + } else { saveFile(save(dataset), dataset.filename, "text/turtle"); setDirty(false); } @@ -84,42 +83,57 @@ const DiagramPersistence = (props: any) => { } return ( - - - - - {examples.map(e => - loadExample(e.path)}> - {e.name} - )} - - - + {/* Load Example dropdown */} + + {examples.map((e) => ( + loadExample(e.path)}> + {e.name} + + ))} + + + {/* TTL Load/Save buttons */} + + + + {/* Reference Types Only toggle – styled like a normal button */} + - {uploadText} - setRefDataOnly(!refDataOnly)} - /> - - - - - - + Reference Types only + + + + {/* Error message if any */} + {uploadText && {uploadText}} +
); }; diff --git a/editor-app/components/EditInstalledComponent.tsx b/editor-app/components/EditInstalledComponent.tsx new file mode 100644 index 0000000..58fc6a8 --- /dev/null +++ b/editor-app/components/EditInstalledComponent.tsx @@ -0,0 +1,864 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { Button, Modal, Form, Table, Alert } from "react-bootstrap"; +import { v4 as uuidv4 } from "uuid"; +import { Individual, Installation, EntityType } from "@/lib/Schema"; +import { Model } from "@/lib/Model"; + +interface Props { + show: boolean; + setShow: (show: boolean) => void; + individual: Individual | undefined; + setIndividual: (individual: Individual) => void; + dataset: Model; + updateDataset?: (updater: (d: Model) => void) => void; + targetSlotId?: string; + targetSystemId?: string; +} + +// Interface for slot options that includes nesting info +interface SlotOption { + id: string; + virtualId: string; + displayName: string; + bounds: { beginning: number; ending: number }; + parentPath: string; + nestingLevel: number; + systemName?: string; + scInstallationId?: string; +} + +const EditInstalledComponent = (props: Props) => { + const { + show, + setShow, + individual, + setIndividual, + dataset, + updateDataset, + targetSlotId, + targetSystemId, + } = props; + + const [localInstallations, setLocalInstallations] = useState( + [] + ); + const [allInstallations, setAllInstallations] = useState([]); + const [removedInstallations, setRemovedInstallations] = useState< + Installation[] + >([]); + const [errors, setErrors] = useState([]); + const [rawInputs, setRawInputs] = useState< + Map + >(new Map()); + const [showAll, setShowAll] = useState(false); + + // Get available slots + const availableSlots = useMemo((): SlotOption[] => { + const slots: SlotOption[] = []; + const displayIndividuals = dataset.getDisplayIndividuals(); + + displayIndividuals.forEach((ind) => { + if (!ind._isVirtualRow) return; + + const originalId = ind.id.split("__installed_in__")[0]; + const original = dataset.individuals.get(originalId); + if (!original) return; + + const origType = original.entityType ?? EntityType.Individual; + if (origType !== EntityType.SystemComponent) return; + + const pathParts = ind._parentPath?.split("__") || []; + const pathNames: string[] = []; + + pathParts.forEach((partId) => { + const part = dataset.individuals.get(partId); + if (part) { + pathNames.push(part.name); + } + }); + + const hierarchyStr = + pathNames.length > 0 ? pathNames.join(" → ") : "Unknown"; + const displayName = `${ind.name} (in ${hierarchyStr})`; + const scInstallationId = ind._installationId; + + slots.push({ + id: originalId, + virtualId: ind.id, + displayName: displayName, + bounds: { beginning: ind.beginning, ending: ind.ending }, + parentPath: ind._parentPath || "", + nestingLevel: ind._nestingLevel || 1, + systemName: pathNames[0], + scInstallationId: scInstallationId, + }); + }); + + slots.sort((a, b) => { + if (a.systemName !== b.systemName) { + return (a.systemName || "").localeCompare(b.systemName || ""); + } + if (a.parentPath !== b.parentPath) { + if (a.parentPath.startsWith(b.parentPath + "__")) return 1; + if (b.parentPath.startsWith(a.parentPath + "__")) return -1; + return a.parentPath.localeCompare(b.parentPath); + } + if (a.nestingLevel !== b.nestingLevel) { + return a.nestingLevel - b.nestingLevel; + } + return a.displayName.localeCompare(b.displayName); + }); + + return slots; + }, [dataset]); + + const slotsBySystem = useMemo(() => { + const groups = new Map(); + availableSlots.forEach((slot) => { + const sysName = slot.systemName || "Unknown"; + if (!groups.has(sysName)) { + groups.set(sysName, []); + } + groups.get(sysName)!.push(slot); + }); + return groups; + }, [availableSlots]); + + const getSlotOptionByVirtualId = ( + virtualId: string + ): SlotOption | undefined => { + return availableSlots.find((slot) => slot.virtualId === virtualId); + }; + + const getSlotOption = ( + targetId: string, + scInstContextId?: string + ): SlotOption | undefined => { + return availableSlots.find((slot) => { + if (slot.id !== targetId) return false; + if (scInstContextId) { + return slot.scInstallationId === scInstContextId; + } + return true; + }); + }; + + const getSlotTimeBounds = ( + slotId: string, + scInstContextId?: string + ): { beginning: number; ending: number; slotName: string } => { + const slotOption = getSlotOption(slotId, scInstContextId); + if (slotOption) { + return { + beginning: slotOption.bounds.beginning, + ending: slotOption.bounds.ending, + slotName: slotOption.displayName, + }; + } + + const slot = dataset.individuals.get(slotId); + if (!slot) { + return { beginning: 0, ending: Model.END_OF_TIME, slotName: slotId }; + } + + let beginning = slot.beginning; + let ending = slot.ending; + + if (slot.installations && slot.installations.length > 0) { + const instBeginnings = slot.installations.map((inst) => + Math.max(0, inst.beginning ?? 0) + ); + const instEndings = slot.installations.map( + (inst) => inst.ending ?? Model.END_OF_TIME + ); + beginning = Math.min(...instBeginnings); + ending = Math.max(...instEndings); + } + + if (beginning < 0) beginning = 0; + + return { beginning, ending, slotName: slot.name }; + }; + + useEffect(() => { + if (individual && individual.installations) { + const allInst = [...individual.installations]; + setAllInstallations(allInst); + + if (targetSlotId) { + let filtered = allInst.filter((inst) => inst.targetId === targetSlotId); + if (targetSystemId) { + filtered = filtered.filter( + (inst) => + !inst.systemContextId || inst.systemContextId === targetSystemId + ); + } + setLocalInstallations(filtered); + setShowAll(false); + } else { + setLocalInstallations(allInst); + setShowAll(true); + } + + const inputs = new Map(); + allInst.forEach((inst) => { + inputs.set(inst.id, { + beginning: String(inst.beginning ?? 0), + // Use empty string for undefined/END_OF_TIME to show placeholder + ending: + inst.ending === undefined || inst.ending >= Model.END_OF_TIME + ? "" + : String(inst.ending), + }); + }); + setRawInputs(inputs); + } else { + setLocalInstallations([]); + setAllInstallations([]); + setRawInputs(new Map()); + setShowAll(!targetSlotId); + } + setRemovedInstallations([]); + setErrors([]); + }, [individual, show, targetSlotId, targetSystemId]); + + const handleClose = () => { + setShow(false); + setRemovedInstallations([]); + setErrors([]); + setShowAll(false); + }; + + const validateInstallations = (): boolean => { + const newErrors: string[] = []; + + localInstallations.forEach((inst, idx) => { + const raw = rawInputs.get(inst.id); + const beginningStr = raw?.beginning ?? String(inst.beginning); + const endingStr = raw?.ending ?? ""; + + const slotInfo = inst.targetId + ? getSlotTimeBounds(inst.targetId, inst.scInstallationContextId) + : { + beginning: 0, + ending: Model.END_OF_TIME, + slotName: `Row ${idx + 1}`, + }; + const slotName = slotInfo.slotName; + + if (!inst.targetId) { + newErrors.push(`Row ${idx + 1}: Please select a target slot.`); + return; + } + + if (beginningStr.trim() === "") { + newErrors.push(`${slotName}: "From" time is required.`); + } + + // "Until" is now optional - empty means inherit from parent + + const beginning = parseInt(beginningStr, 10); + // Parse ending: empty string means inherit from parent slot + const ending = + endingStr.trim() === "" ? slotInfo.ending : parseInt(endingStr, 10); + + if (!isNaN(beginning)) { + if (beginning < 0) { + newErrors.push(`${slotName}: "From" cannot be negative.`); + } + + if (beginning < slotInfo.beginning) { + newErrors.push( + `${slotName}: "From" (${beginning}) cannot be before slot starts (${slotInfo.beginning}).` + ); + } + } + + if (endingStr.trim() !== "" && !isNaN(ending)) { + if (ending < 1) { + newErrors.push(`${slotName}: "Until" must be at least 1.`); + } + if (!isNaN(beginning) && beginning >= ending) { + newErrors.push(`${slotName}: "From" must be less than "Until".`); + } + // Allow ending to be equal to slot ending (changed from > to >) + if (slotInfo.ending < Model.END_OF_TIME && ending > slotInfo.ending) { + newErrors.push( + `${slotName}: "Until" (${ending}) cannot be after slot ends (${slotInfo.ending}).` + ); + } + } + }); + + // Check for overlapping periods in the same slot AND same context + const bySlotAndContext = new Map(); + localInstallations.forEach((inst) => { + if (!inst.targetId) return; + const raw = rawInputs.get(inst.id); + const beginning = parseInt(raw?.beginning ?? String(inst.beginning), 10); + const endingStr = raw?.ending ?? ""; + + // Get slot bounds for this installation + const slotInfo = getSlotTimeBounds( + inst.targetId, + inst.scInstallationContextId + ); + // If ending is empty, use slot ending + const ending = + endingStr.trim() === "" ? slotInfo.ending : parseInt(endingStr, 10); + if (isNaN(beginning)) return; + + const key = `${inst.targetId}__${inst.scInstallationContextId || "any"}`; + const list = bySlotAndContext.get(key) || []; + list.push({ ...inst, beginning, ending }); + bySlotAndContext.set(key, list); + }); + + bySlotAndContext.forEach((installations, key) => { + if (installations.length < 2) return; + const [slotId, contextId] = key.split("__"); + const slotInfo = getSlotTimeBounds( + slotId, + contextId !== "any" ? contextId : undefined + ); + + installations.sort((a, b) => (a.beginning ?? 0) - (b.beginning ?? 0)); + + for (let i = 0; i < installations.length - 1; i++) { + const current = installations[i]; + const next = installations[i + 1]; + const currentEnding = current.ending ?? slotInfo.ending; + const nextBeginning = next.beginning ?? 0; + if (currentEnding > nextBeginning) { + const currentEndingStr = + currentEnding >= Model.END_OF_TIME ? "∞" : currentEnding; + const nextEndingStr = + (next.ending ?? slotInfo.ending) >= Model.END_OF_TIME + ? "∞" + : next.ending ?? slotInfo.ending; + newErrors.push( + `${slotInfo.slotName}: Periods overlap (${current.beginning}-${currentEndingStr} and ${nextBeginning}-${nextEndingStr}).` + ); + } + } + }); + + setErrors(newErrors); + return newErrors.length === 0; + }; + + const handleSave = () => { + if (!individual) return; + + if (!validateInstallations()) { + return; + } + + const updatedInstallations = localInstallations.map((inst) => { + const raw = rawInputs.get(inst.id); + const endingStr = raw?.ending ?? ""; + + // Get slot bounds for this installation + const slotInfo = inst.targetId + ? getSlotTimeBounds(inst.targetId, inst.scInstallationContextId) + : { beginning: 0, ending: Model.END_OF_TIME, slotName: "" }; + + // If ending is empty, inherit from parent slot + let endingValue: number | undefined; + if (endingStr.trim() === "") { + // Inherit from parent - use parent's ending if it's not infinity + endingValue = + slotInfo.ending < Model.END_OF_TIME ? slotInfo.ending : undefined; + } else { + endingValue = parseInt(endingStr, 10); + } + + return { + ...inst, + beginning: parseInt(raw?.beginning ?? String(inst.beginning), 10) || 0, + ending: endingValue, + }; + }); + + let finalInstallations: Installation[]; + + if (targetSlotId && !showAll) { + const keptFromOtherSlots = allInstallations.filter( + (i) => i.targetId !== targetSlotId + ); + const removedIds = new Set(removedInstallations.map((i) => i.id)); + const filteredUpdated = updatedInstallations.filter( + (i) => !removedIds.has(i.id) + ); + finalInstallations = [...keptFromOtherSlots, ...filteredUpdated]; + } else { + const removedIds = new Set(removedInstallations.map((i) => i.id)); + finalInstallations = updatedInstallations.filter( + (i) => !removedIds.has(i.id) + ); + } + + if (updateDataset) { + updateDataset((d: Model) => { + removedInstallations.forEach((removedInst) => { + const participationPatterns = [ + `${individual.id}__installed_in__${removedInst.targetId}__${removedInst.id}`, + `${individual.id}__installed_in__${removedInst.targetId}`, + ]; + + d.activities.forEach((activity) => { + const parts = activity.participations; + if (!parts || !(parts instanceof Map)) return; + + participationPatterns.forEach((pattern) => { + parts.forEach((_, key) => { + if (key.startsWith(pattern)) { + parts.delete(key); + } + }); + }); + + d.activities.set(activity.id, activity); + }); + }); + + const updated: Individual = { + ...individual, + installations: finalInstallations, + }; + d.addIndividual(updated); + }); + } else { + const updated: Individual = { + ...individual, + installations: finalInstallations, + }; + setIndividual(updated); + } + + handleClose(); + }; + + const addInstallation = () => { + let slotOption: SlotOption | undefined; + if (targetSlotId) { + slotOption = availableSlots.find((s) => s.id === targetSlotId); + } + + const defaultBeginning = slotOption?.bounds.beginning ?? 0; + + const newInst: Installation = { + id: uuidv4(), + componentId: individual?.id || "", + targetId: targetSlotId || "", + beginning: defaultBeginning, + ending: undefined, // Default to undefined (infinity) + scInstallationContextId: slotOption?.scInstallationId, + }; + + setLocalInstallations((prev) => [...prev, newInst]); + setRawInputs((prev) => { + const next = new Map(prev); + next.set(newInst.id, { + beginning: String(defaultBeginning), + ending: "", // Empty string for infinity + }); + return next; + }); + }; + + const removeInstallation = (instId: string) => { + const removed = localInstallations.find((inst) => inst.id === instId); + if (removed) { + setRemovedInstallations((prev) => [...prev, removed]); + } + setLocalInstallations((prev) => prev.filter((inst) => inst.id !== instId)); + setRawInputs((prev) => { + const next = new Map(prev); + next.delete(instId); + return next; + }); + setErrors([]); + }; + + const updateInstallation = ( + instId: string, + field: keyof Installation, + value: any + ) => { + setLocalInstallations((prev) => + prev.map((inst) => + inst.id === instId ? { ...inst, [field]: value } : inst + ) + ); + if (errors.length > 0) { + setErrors([]); + } + }; + + const updateRawInput = ( + instId: string, + field: "beginning" | "ending", + value: string + ) => { + setRawInputs((prev) => { + const next = new Map(prev); + const current = next.get(instId) || { beginning: "0", ending: "" }; + next.set(instId, { ...current, [field]: value }); + return next; + }); + + if (value !== "") { + const parsed = parseInt(value, 10); + if (!isNaN(parsed)) { + updateInstallation(instId, field, parsed); + } + } else if (field === "ending") { + // Empty ending means undefined + updateInstallation(instId, field, undefined); + } + + if (errors.length > 0) { + setErrors([]); + } + }; + + const isOutsideSlotBounds = ( + instId: string, + field: "beginning" | "ending" + ): boolean => { + const inst = localInstallations.find((i) => i.id === instId); + if (!inst || !inst.targetId) return false; + + const raw = rawInputs.get(instId); + const rawValue = field === "beginning" ? raw?.beginning : raw?.ending; + + // Empty ending is valid (infinity) + if (field === "ending" && (!rawValue || rawValue.trim() === "")) { + return false; + } + + const value = parseInt(rawValue ?? "", 10); + if (isNaN(value)) return false; + + const slotBounds = getSlotTimeBounds( + inst.targetId, + inst.scInstallationContextId + ); + + if (field === "beginning") { + return value < slotBounds.beginning; + } else { + return slotBounds.ending < Model.END_OF_TIME && value > slotBounds.ending; + } + }; + + if (!individual) return null; + + const isFiltered = !!targetSlotId && !showAll; + const totalInstallations = allInstallations.length; + + const modalTitle = isFiltered + ? `Edit Installation: ${individual.name}` + : `Edit All Installations: ${individual.name}`; + + return ( + + + {modalTitle} + + + {isFiltered && totalInstallations > localInstallations.length && ( +
+ +
+ )} + +

+ {isFiltered + ? `Manage installation periods for this component. You can have multiple non-overlapping periods. Leave "Until" empty for indefinite.` + : `Manage all installation periods for this component across different slots. Leave "Until" empty for indefinite (∞).`} + {!isFiltered && availableSlots.length === 0 && ( + <> + {" "} + System Components must be installed in a System before they can + receive Installed Components.{" "} + + )} +

+ + + + + + {!isFiltered && ( + + )} + + + + + + + {localInstallations.length === 0 ? ( + + + + ) : ( + localInstallations.map((inst, idx) => { + const raw = rawInputs.get(inst.id) || { + beginning: String(inst.beginning), + ending: "", + }; + + const slotOption = inst.targetId + ? getSlotOption(inst.targetId, inst.scInstallationContextId) + : undefined; + + const instSlotBounds = inst.targetId + ? getSlotTimeBounds( + inst.targetId, + inst.scInstallationContextId + ) + : null; + + const beginningOutOfBounds = isOutsideSlotBounds( + inst.id, + "beginning" + ); + const endingOutOfBounds = isOutsideSlotBounds( + inst.id, + "ending" + ); + + return ( + + + {!isFiltered && ( + + )} + + + + + ); + }) + )} + +
+ # + + Target Slot * + + From * + Until + Actions +
+ + No installations yet. Click "+ Add Installation Period" + below to add one. + +
{idx + 1} + { + const virtualId = e.target.value; + if (!virtualId) { + updateInstallation(inst.id, "targetId", ""); + updateInstallation( + inst.id, + "scInstallationContextId", + undefined + ); + return; + } + + const selectedSlot = + getSlotOptionByVirtualId(virtualId); + + if (selectedSlot) { + updateInstallation( + inst.id, + "targetId", + selectedSlot.id + ); + updateInstallation( + inst.id, + "scInstallationContextId", + selectedSlot.scInstallationId + ); + + updateRawInput( + inst.id, + "beginning", + String(selectedSlot.bounds.beginning) + ); + // Leave ending empty (infinity) by default + updateRawInput(inst.id, "ending", ""); + } + }} + className={!inst.targetId ? "border-warning" : ""} + > + + {Array.from(slotsBySystem.entries()).map( + ([sysName, slots]) => ( + + {slots.map((slot) => { + const indent = " ".repeat( + slot.nestingLevel - 1 + ); + const boundsStr = ` (${ + slot.bounds.beginning + }-${ + slot.bounds.ending >= Model.END_OF_TIME + ? "∞" + : slot.bounds.ending + })`; + return ( + + ); + })} + + ) + )} + + {slotOption && ( + + + Available: {slotOption.bounds.beginning}- + {slotOption.bounds.ending >= Model.END_OF_TIME + ? "∞" + : slotOption.bounds.ending} + + + )} + + + updateRawInput(inst.id, "beginning", e.target.value) + } + placeholder={String(instSlotBounds?.beginning ?? 0)} + className={ + raw.beginning === "" || beginningOutOfBounds + ? "border-danger" + : "" + } + isInvalid={beginningOutOfBounds} + /> + {beginningOutOfBounds && instSlotBounds && ( + + Min: {instSlotBounds.beginning} + + )} + + + updateRawInput(inst.id, "ending", e.target.value) + } + placeholder={ + instSlotBounds && + instSlotBounds.ending < Model.END_OF_TIME + ? String(instSlotBounds.ending) + : "∞" + } + className={endingOutOfBounds ? "border-danger" : ""} + isInvalid={endingOutOfBounds} + /> + {endingOutOfBounds && instSlotBounds && ( + + Max:{" "} + {instSlotBounds.ending >= Model.END_OF_TIME + ? "∞" + : instSlotBounds.ending} + + )} + + +
+ +
+ +
+ + {errors.length > 0 && ( + + Please fix the following: +
    + {errors.map((error, i) => ( +
  • {error}
  • + ))} +
+
+ )} +
+ +
+ {isFiltered + ? `${localInstallations.length} period${ + localInstallations.length !== 1 ? "s" : "" + } in this slot` + : `${localInstallations.length} total installation${ + localInstallations.length !== 1 ? "s" : "" + }`} +
+
+ + +
+
+
+ ); +}; + +export default EditInstalledComponent; diff --git a/editor-app/components/EditSystemComponentInstallation.tsx b/editor-app/components/EditSystemComponentInstallation.tsx new file mode 100644 index 0000000..b68d0f1 --- /dev/null +++ b/editor-app/components/EditSystemComponentInstallation.tsx @@ -0,0 +1,732 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Button from "react-bootstrap/Button"; +import Modal from "react-bootstrap/Modal"; +import Form from "react-bootstrap/Form"; +import Table from "react-bootstrap/Table"; +import Badge from "react-bootstrap/Badge"; +import { Model } from "@/lib/Model"; +import { EntityType, Individual, Installation } from "@/lib/Schema"; +import { v4 as uuidv4 } from "uuid"; + +interface Props { + show: boolean; + setShow: (show: boolean) => void; + individual: Individual | undefined; + setIndividual: (individual: Individual) => void; + dataset: Model; + updateDataset: (updater: (d: Model) => void) => void; + targetSystemId?: string; +} + +interface RawInputs { + [installationId: string]: { + beginning: string; + ending: string; + }; +} + +interface ValidationErrors { + [installationId: string]: { + beginning?: string; + ending?: string; + overlap?: string; + target?: string; + }; +} + +// Interface for available targets (including virtual SC instances) +interface TargetOption { + id: string; // The original target ID (System or SC ID) + virtualId: string; // Unique identifier for this specific instance + displayName: string; + entityType: EntityType; + bounds: { beginning: number; ending: number }; + isVirtual: boolean; + parentSystemName?: string; + scInstallationId?: string; // The SC's installation ID for context +} + +// Helper function to check if two time ranges overlap +function hasTimeOverlap( + start1: number, + end1: number, + start2: number, + end2: number +): boolean { + return start1 < end2 && start2 < end1; +} + +// Helper to extract the SC installation ID from a virtual row ID +function extractInstallationIdFromVirtualId( + virtualId: string +): string | undefined { + if (!virtualId.includes("__installed_in__")) return undefined; + const parts = virtualId.split("__installed_in__"); + if (parts.length < 2) return undefined; + let rest = parts[1]; + + const ctxIndex = rest.indexOf("__ctx_"); + if (ctxIndex !== -1) { + rest = rest.substring(0, ctxIndex); + } + + const restParts = rest.split("__"); + return restParts.length > 1 ? restParts[1] : undefined; +} + +export default function EditSystemComponentInstallation({ + show, + setShow, + individual, + setIndividual, + dataset, + updateDataset, + targetSystemId, +}: Props) { + const [installations, setInstallations] = useState([]); + const [rawInputs, setRawInputs] = useState({}); + const [errors, setErrors] = useState({}); + + // Initialize installations when modal opens + useEffect(() => { + if (show && individual) { + const insts = individual.installations || []; + setInstallations([...insts]); + + // Initialize raw inputs + const inputs: RawInputs = {}; + insts.forEach((inst) => { + inputs[inst.id] = { + beginning: inst.beginning?.toString() ?? "0", + ending: inst.ending?.toString() ?? "", + }; + }); + setRawInputs(inputs); + setErrors({}); + } + }, [show, individual]); + + // Get available targets (Systems AND SystemComponent installations) + const availableTargets = useMemo((): TargetOption[] => { + const targets: TargetOption[] = []; + + // Add Systems as targets + dataset.individuals.forEach((ind) => { + const entityType = ind.entityType ?? EntityType.Individual; + + if (entityType === EntityType.System) { + const bounds = dataset.getTargetTimeBounds(ind.id); + targets.push({ + id: ind.id, + virtualId: ind.id, // For systems, virtualId = id + displayName: ind.name, + entityType: EntityType.System, + bounds, + isVirtual: false, + }); + } + }); + + // Add SystemComponent instances (from the display list) as targets + const displayIndividuals = dataset.getDisplayIndividuals(); + + displayIndividuals.forEach((ind) => { + if (!ind._isVirtualRow) return; + + const originalId = ind.id.split("__installed_in__")[0]; + const original = dataset.individuals.get(originalId); + if (!original) return; + + const origType = original.entityType ?? EntityType.Individual; + if (origType !== EntityType.SystemComponent) return; + + // Don't allow installing into self (same original SC ID) + if (originalId === individual?.id) return; + + // Extract the SC installation ID (this is the context) + const scInstallationId = ind._installationId; + + // Check for circular reference - only block if it would create a chain cycle + // SC1 → SC2 and SC2 → SC1 as parallel installations is allowed + // SC1 → SC2 → SC1 (same chain) is blocked + if (individual) { + if ( + dataset.wouldCreateCircularReference( + individual.id, + originalId, + scInstallationId // Pass the specific installation context + ) + ) { + console.log( + `Blocking circular reference: ${individual.name} -> ${original.name} (context: ${scInstallationId})` + ); + return; + } + } + + // Extract parent system name for display + const pathParts = ind._parentPath?.split("__") || []; + const systemId = pathParts[0]; + const system = dataset.individuals.get(systemId); + const parentSystemName = system?.name; + + // Build a better display name showing full path + const pathNames: string[] = []; + pathParts.forEach((partId) => { + const part = dataset.individuals.get(partId); + if (part) { + pathNames.push(part.name); + } + }); + const hierarchyStr = + pathNames.length > 0 ? pathNames.join(" → ") : "Unknown"; + + targets.push({ + id: originalId, + virtualId: ind.id, // Use the FULL virtual ID as unique key + displayName: `${ind.name} (in ${hierarchyStr})`, + entityType: EntityType.SystemComponent, + bounds: { beginning: ind.beginning, ending: ind.ending }, + isVirtual: true, + parentSystemName, + scInstallationId, + }); + }); + + return targets; + }, [dataset, individual?.id]); + + // Helper to get target by virtualId + const getTargetByVirtualId = ( + virtualId: string + ): TargetOption | undefined => { + return availableTargets.find((t) => t.virtualId === virtualId); + }; + + // Helper to get target for an installation (by id + context) + const getTargetForInstallation = ( + inst: Installation + ): TargetOption | undefined => { + if (!inst.targetId) return undefined; + + // First try to find by exact context match + if (inst.scInstallationContextId) { + const match = availableTargets.find( + (t) => + t.id === inst.targetId && + t.scInstallationId === inst.scInstallationContextId + ); + if (match) return match; + } + + // For Systems (non-virtual), just match by ID + const systemMatch = availableTargets.find( + (t) => t.id === inst.targetId && !t.isVirtual + ); + if (systemMatch) return systemMatch; + + // Fallback: return first match (shouldn't happen if data is consistent) + return availableTargets.find((t) => t.id === inst.targetId); + }; + + // Helper function to get effective target time bounds + const getTargetTimeBounds = ( + targetId: string, + scInstContextId?: string + ): { + beginning: number; + ending: number; + targetName: string; + targetType: EntityType; + } => { + // Try to find the specific target option + const targetOption = scInstContextId + ? availableTargets.find( + (t) => t.id === targetId && t.scInstallationId === scInstContextId + ) + : availableTargets.find((t) => t.id === targetId); + + if (targetOption) { + return { + beginning: targetOption.bounds.beginning, + ending: targetOption.bounds.ending, + targetName: targetOption.displayName, + targetType: targetOption.entityType, + }; + } + + // Fallback + const target = dataset.individuals.get(targetId); + if (!target) { + return { + beginning: 0, + ending: Model.END_OF_TIME, + targetName: targetId, + targetType: EntityType.System, + }; + } + + const targetType = target.entityType ?? EntityType.Individual; + const bounds = dataset.getTargetTimeBounds(targetId); + + return { + ...bounds, + targetName: target.name, + targetType: targetType as EntityType, + }; + }; + + // Validate all installations for overlaps + const validateAllInstallations = ( + currentInstallations: Installation[], + currentRawInputs: RawInputs + ) => { + const newErrors: ValidationErrors = {}; + + currentInstallations.forEach((inst) => { + newErrors[inst.id] = {}; + const raw = currentRawInputs[inst.id] || { beginning: "0", ending: "" }; + + if (inst.targetId === individual?.id) { + newErrors[inst.id].target = "Cannot install into itself"; + return; + } + + // Get target bounds using context + const targetOption = getTargetForInstallation(inst); + const bounds = targetOption?.bounds || { + beginning: 0, + ending: Model.END_OF_TIME, + }; + + const beginning = raw.beginning === "" ? 0 : parseFloat(raw.beginning); + // If ending is empty, inherit from parent bounds + const ending = raw.ending === "" ? bounds.ending : parseFloat(raw.ending); + + if (isNaN(beginning)) { + newErrors[inst.id].beginning = "Must be a number"; + } else if (beginning < bounds.beginning) { + newErrors[ + inst.id + ].beginning = `Must be ≥ ${bounds.beginning} (target start)`; + } else if ( + beginning >= bounds.ending && + bounds.ending < Model.END_OF_TIME + ) { + newErrors[ + inst.id + ].beginning = `Must be < ${bounds.ending} (target end)`; + } + + if (raw.ending !== "" && isNaN(ending)) { + newErrors[inst.id].ending = "Must be a number"; + } else if (ending <= beginning) { + newErrors[inst.id].ending = "Must be > beginning"; + } else if (ending > bounds.ending && bounds.ending < Model.END_OF_TIME) { + // Changed from > to > (allow equal) + newErrors[inst.id].ending = `Must be ≤ ${bounds.ending} (target end)`; + } + + // Check for overlapping installations into the SAME target AND context + if (inst.targetId) { + const key = `${inst.targetId}__${ + inst.scInstallationContextId || "none" + }`; + const otherInstallationsInSameTarget = currentInstallations.filter( + (other) => { + if (other.id === inst.id) return false; + const otherKey = `${other.targetId}__${ + other.scInstallationContextId || "none" + }`; + return otherKey === key; + } + ); + + for (const other of otherInstallationsInSameTarget) { + const otherRaw = currentRawInputs[other.id] || { + beginning: "0", + ending: "", + }; + const otherBeginning = + otherRaw.beginning === "" ? 0 : parseFloat(otherRaw.beginning); + // If other ending is empty, inherit from bounds + const otherEnding = + otherRaw.ending === "" + ? bounds.ending + : parseFloat(otherRaw.ending); + + if ( + !isNaN(beginning) && + !isNaN(ending) && + !isNaN(otherBeginning) && + !isNaN(otherEnding) + ) { + if ( + hasTimeOverlap(beginning, ending, otherBeginning, otherEnding) + ) { + newErrors[ + inst.id + ].overlap = `Overlaps with another installation in the same target (${otherBeginning}-${ + otherEnding === Model.END_OF_TIME ? "∞" : otherEnding + })`; + break; + } + } + } + } + }); + + setErrors(newErrors); + return newErrors; + }; + + // Add new installation + const addInstallation = () => { + const newInst: Installation = { + id: uuidv4(), + componentId: individual?.id ?? "", + targetId: "", + beginning: 0, + ending: undefined, + scInstallationContextId: undefined, + }; + const newInstallations = [...installations, newInst]; + const newRawInputs = { + ...rawInputs, + [newInst.id]: { beginning: "0", ending: "" }, + }; + setInstallations(newInstallations); + setRawInputs(newRawInputs); + validateAllInstallations(newInstallations, newRawInputs); + }; + + // Remove installation + const removeInstallation = (instId: string) => { + const newInstallations = installations.filter((i) => i.id !== instId); + const newRawInputs = { ...rawInputs }; + delete newRawInputs[instId]; + setInstallations(newInstallations); + setRawInputs(newRawInputs); + validateAllInstallations(newInstallations, newRawInputs); + }; + + // Update raw input and validate + const updateRawInput = ( + instId: string, + field: "beginning" | "ending", + value: string + ) => { + const newRawInputs = { + ...rawInputs, + [instId]: { + ...rawInputs[instId], + [field]: value, + }, + }; + setRawInputs(newRawInputs); + validateAllInstallations(installations, newRawInputs); + }; + + // Update installation target using virtualId + const updateInstallationTarget = (instId: string, virtualId: string) => { + if (!virtualId) { + // Clear target + const newInstallations = installations.map((i) => + i.id === instId + ? { ...i, targetId: "", scInstallationContextId: undefined } + : i + ); + setInstallations(newInstallations); + validateAllInstallations(newInstallations, rawInputs); + return; + } + + const targetOption = getTargetByVirtualId(virtualId); + if (!targetOption) return; + + const newInstallations = installations.map((i) => + i.id === instId + ? { + ...i, + targetId: targetOption.id, + scInstallationContextId: targetOption.scInstallationId, + } + : i + ); + setInstallations(newInstallations); + + // Reset times to target bounds + const newRawInputs = { + ...rawInputs, + [instId]: { + beginning: String(targetOption.bounds.beginning), + ending: "", + }, + }; + setRawInputs(newRawInputs); + validateAllInstallations(newInstallations, newRawInputs); + }; + + // Check if there are any validation errors + const hasErrors = () => { + return Object.values(errors).some( + (e) => e.beginning || e.ending || e.overlap || e.target + ); + }; + + // Check if all installations have targets + const allHaveTargets = () => { + return installations.every((i) => i.targetId); + }; + + // Save changes + const handleSave = () => { + if (!individual || hasErrors() || !allHaveTargets()) return; + + const finalErrors = validateAllInstallations(installations, rawInputs); + const hasFinalErrors = Object.values(finalErrors).some( + (e) => e.beginning || e.ending || e.overlap || e.target + ); + if (hasFinalErrors) return; + + const finalInstallations = installations.map((inst) => { + const raw = rawInputs[inst.id]; + const targetOption = getTargetForInstallation(inst); + const bounds = targetOption?.bounds || { + beginning: 0, + ending: Model.END_OF_TIME, + }; + + // If ending is empty, use parent bounds ending + const endingValue = + raw?.ending === "" + ? bounds.ending < Model.END_OF_TIME + ? bounds.ending + : undefined + : parseFloat(raw?.ending ?? ""); + + return { + ...inst, + beginning: + raw?.beginning === "" ? 0 : parseFloat(raw?.beginning ?? "0"), + ending: endingValue, + }; + }); + + const updated: Individual = { + ...individual, + installations: finalInstallations, + }; + + updateDataset((d: Model) => { + d.individuals.set(individual.id, updated); + }); + + setShow(false); + }; + + const onHide = () => setShow(false); + + if (!individual) return null; + + // Group targets by type for the dropdown + const systemTargets = availableTargets.filter( + (t) => t.entityType === EntityType.System + ); + const scTargets = availableTargets.filter( + (t) => t.entityType === EntityType.SystemComponent + ); + + return ( + + + Edit Installations for {individual.name} + + +

+ Configure where this System Component is installed. You can install it + into Systems or other System Components (nested slots). +

+ + + + + + + + + + + + {installations.length === 0 ? ( + + + + ) : ( + installations.map((inst) => { + const raw = rawInputs[inst.id] || { + beginning: "0", + ending: "", + }; + const err = errors[inst.id] || {}; + const targetOption = getTargetForInstallation(inst); + + return ( + + + + + + + ); + }) + )} + +
TargetBeginningEndingActions
+ + No installations yet. Click "+ Add Installation" below to + add one. + +
+ + updateInstallationTarget(inst.id, e.target.value) + } + className={ + !inst.targetId + ? "border-warning" + : err.target + ? "border-danger" + : "" + } + > + + + {systemTargets.map((target) => { + const boundsStr = + target.bounds.ending < Model.END_OF_TIME + ? ` (${target.bounds.beginning}-${target.bounds.ending})` + : target.bounds.beginning > 0 + ? ` (${target.bounds.beginning}-∞)` + : ""; + return ( + + ); + })} + + {scTargets.length > 0 && ( + + {scTargets.map((target) => { + const boundsStr = + target.bounds.ending < Model.END_OF_TIME + ? ` (${target.bounds.beginning}-${target.bounds.ending})` + : target.bounds.beginning > 0 + ? ` (${target.bounds.beginning}-∞)` + : ""; + return ( + + ); + })} + + )} + + {targetOption && ( + + + {targetOption.entityType === + EntityType.SystemComponent + ? " " + : " "} + Available: {targetOption.bounds.beginning}- + {targetOption.bounds.ending >= Model.END_OF_TIME + ? "∞" + : targetOption.bounds.ending} + + + )} + {err.target && ( + + {err.target} + + )} + {err.overlap && ( + + {err.overlap} + + )} + + + updateRawInput(inst.id, "beginning", e.target.value) + } + isInvalid={!!err.beginning} + disabled={!inst.targetId} + /> + {err.beginning && ( + + {err.beginning} + + )} + + + updateRawInput(inst.id, "ending", e.target.value) + } + isInvalid={!!err.ending} + placeholder="∞" + disabled={!inst.targetId} + /> + {err.ending && ( + + {err.ending} + + )} + + +
+ + +
+ + + + +
+ ); +} diff --git a/editor-app/components/EntityTypeLegend.tsx b/editor-app/components/EntityTypeLegend.tsx new file mode 100644 index 0000000..5e8ad9b --- /dev/null +++ b/editor-app/components/EntityTypeLegend.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import Card from "react-bootstrap/Card"; + +interface EntityLegendItem { + icon: string; + label: string; + description?: string; + hasHatch?: boolean; +} + +export const entityTypes: EntityLegendItem[] = [ + { + icon: "▣", + label: "System", + description: "A system containing component slots", + }, + { + icon: "◇", + label: "System Component", + description: "A slot/role within a system (uninstalled)", + }, + { + icon: "◆", + label: "SC in System", + description: "A system component installed in a system", + }, + { + icon: "◈", + label: "SC in SC", + description: "A system component nested inside another system component", + }, + { + icon: "◆", + label: "SC with children", + description: "A system component that has other components installed in it", + hasHatch: true, + }, + { + icon: "⬡", + label: "Installed Component", + description: "A physical component (uninstalled)", + }, + { + icon: "⬢", + label: "IC in SC", + description: "An installed component in a system component slot", + }, + { + icon: "○", + label: "Individual", + description: "A regular individual entity", + }, +]; + +const EntityTypeLegend = () => { + return ( + + + Entity Types + {entityTypes.map((item, idx) => ( +
+ + {item.icon} + {item.hasHatch && ( + + + + + + + + + )} + + {item.label} +
+ ))} +
+
+ ); +}; + +export default EntityTypeLegend; diff --git a/editor-app/components/ExportJson.tsx b/editor-app/components/ExportJson.tsx index 8f10ec9..ad0ea94 100644 --- a/editor-app/components/ExportJson.tsx +++ b/editor-app/components/ExportJson.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; import Button from "react-bootstrap/Button"; import { saveJSONLD } from "lib/ActivityLib"; @@ -6,16 +6,16 @@ const ExportJson = (props: any) => { const { dataset } = props; function downloadjson() { - let pom = document.createElement("a"); + const pom = document.createElement("a"); saveJSONLD(dataset, (obj) => { pom.setAttribute( "href", - "data:text/pldownloadain;charset=utf-8," + + "data:text/plain;charset=utf-8," + encodeURIComponent(JSON.stringify(obj, null, 2)) ); pom.setAttribute("download", "activity_diagram.json"); if (document.createEvent) { - let event = document.createEvent("MouseEvents"); + const event = document.createEvent("MouseEvents"); event.initEvent("click", true, true); pom.dispatchEvent(event); } else { @@ -33,7 +33,7 @@ const ExportJson = (props: any) => { dataset.individuals.size > 0 ? "mx-1 d-block" : "mx-1 d-none" } > - Export JSON + Export JSON ); diff --git a/editor-app/components/ExportJsonLegends.tsx b/editor-app/components/ExportJsonLegends.tsx new file mode 100644 index 0000000..e6f00aa --- /dev/null +++ b/editor-app/components/ExportJsonLegends.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import Button from "react-bootstrap/Button"; +import { saveJSONLD } from "lib/ActivityLib"; +import { Activity } from "@/lib/Schema"; + +interface Props { + dataset: any; + activitiesInView?: Activity[]; + activityColors?: string[]; +} + +const ExportJson = (props: Props) => { + const { dataset, activitiesInView = [], activityColors = [] } = props; + + function downloadjson() { + const pom = document.createElement("a"); + saveJSONLD(dataset, (obj) => { + // Add legend metadata to the exported JSON + const exportData = { + ...obj, + _legend: { + entityTypes: [ + { type: "System", icon: "▣" }, + { type: "SystemComponent", icon: "◇" }, + { type: "SystemComponentInstalled", icon: "◆" }, + { type: "InstalledComponent", icon: "⬡" }, + { type: "InstalledComponentInSlot", icon: "⬢" }, + { type: "Individual", icon: "○" }, + ], + activities: activitiesInView.map((activity, idx) => ({ + id: activity.id, + name: activity.name, + color: activityColors[idx % activityColors.length] || "#ccc", + })), + }, + }; + + pom.setAttribute( + "href", + "data:text/plain;charset=utf-8," + + encodeURIComponent(JSON.stringify(exportData, null, 2)) + ); + pom.setAttribute("download", "activity_diagram.json"); + if (document.createEvent) { + const event = document.createEvent("MouseEvents"); + event.initEvent("click", true, true); + pom.dispatchEvent(event); + } else { + pom.click(); + } + }); + } + + return ( + + ); +}; + +export default ExportJson; diff --git a/editor-app/components/ExportSvg.tsx b/editor-app/components/ExportSvg.tsx index 148c813..da52b3d 100644 --- a/editor-app/components/ExportSvg.tsx +++ b/editor-app/components/ExportSvg.tsx @@ -1,25 +1,216 @@ -import React, { useState, useEffect } from "react"; +import React from "react"; import Button from "react-bootstrap/Button"; -import { saveJSONLD } from "lib/ActivityLib"; +import { Activity } from "@/lib/Schema"; -const ExportJson = (props: any) => { - const { dataset, svgRef } = props; +interface LegendResult { + content: string; + width: number; + height: number; +} - function serializeNode(node: any) { - var svgxml = new XMLSerializer().serializeToString(node); - return svgxml; +interface Props { + dataset: any; + svgRef: React.RefObject; + activitiesInView?: Activity[]; + activityColors?: string[]; +} + +const ExportSvg = (props: Props) => { + const { dataset, svgRef, activitiesInView = [], activityColors = [] } = props; + + function serializeNode(node: SVGSVGElement | null): string { + if (!node) return ""; + const serializer = new XMLSerializer(); + return serializer.serializeToString(node); + } + + // Safe base64 for UTF-8 SVG + function toBase64Utf8(str: string): string { + return window.btoa(unescape(encodeURIComponent(str))); + } + + function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + // Build legend SVG content + function buildLegendSvg(): LegendResult { + const legendWidth = 200; + const itemHeight = 20; + const padding = 10; + let y = padding; + + let legendContent = ""; + + // Entity Types Legend + const entityTypes = [ + { icon: "▣", label: "System" }, + { icon: "◇", label: "System Component" }, + { icon: "◆", label: "SC in System" }, + { icon: "◈", label: "SC in SC" }, + { icon: "⬡", label: "Installed Component" }, + { icon: "⬢", label: "IC in SC" }, + { icon: "○", label: "Individual" }, + ]; + + // Entity Types title + legendContent += `Entity Types`; + y += itemHeight + 5; + + entityTypes.forEach((item) => { + legendContent += `${ + item.icon + }`; + legendContent += `${ + item.label + }`; + y += itemHeight; + }); + + y += 15; // Gap between legends + + // Activity Legend title + legendContent += `Activity Legend`; + y += itemHeight + 5; + + // Activities + activitiesInView.forEach((activity, idx) => { + const color = activityColors[idx % activityColors.length] || "#ccc"; + legendContent += ``; + legendContent += `${escapeXml( + activity.name + )}`; + y += itemHeight; + }); + + // No Activity + legendContent += ``; + legendContent += `No Activity`; + y += itemHeight; + + const legendHeight = y + padding; + + return { content: legendContent, width: legendWidth, height: legendHeight }; } - function downloadsvg(event: any) { - let pom = document.createElement("a"); - pom.setAttribute( - "href", - "data:image/svg+xml;base64," + btoa(serializeNode(svgRef.current)) - ); + function downloadsvg() { + if (!svgRef?.current) return; + + const originalSvg = svgRef.current; + const viewBox = originalSvg.getAttribute("viewBox") || "0 0 1000 500"; + const [, , origWidth, origHeight] = viewBox.split(" ").map(Number); + + // Axis configuration + const AXIS_SIZE = 40; + const AXIS_COLOR = "#9ca3af"; + const AXIS_STROKE_WIDTH = 4; + + // Build legend + const legend = buildLegendSvg(); + const legendWidth = legend.width; + const legendHeight = legend.height; + + // Calculate dimensions for the diagram section (Diagram + Axes) + const diagramSectionWidth = AXIS_SIZE + origWidth; + const diagramSectionHeight = origHeight + AXIS_SIZE; + + // Total SVG dimensions + const totalWidth = legendWidth + 20 + diagramSectionWidth; + const totalHeight = Math.max(legendHeight, diagramSectionHeight); + + // Get original SVG content + const originalContent = originalSvg.innerHTML; + + // Create Axis SVG parts + const defs = ` + + + + + + `; + + const yAxis = ` + + + + + Space + + `; + + const xAxis = ` + + + + + Time + + `; + + // Build combined SVG + const combinedSvg = ` + + ${defs} + + + + + ${legend.content} + + + + + + + ${yAxis} + + + + ${originalContent} + + + + ${xAxis} + + +`; + + const base64 = toBase64Utf8(combinedSvg); + + const pom = document.createElement("a"); + pom.setAttribute("href", "data:image/svg+xml;base64," + base64); pom.setAttribute("download", "activity_diagram.svg"); if (document.createEvent) { - let event = document.createEvent("MouseEvents"); + const event = document.createEvent("MouseEvents"); event.initEvent("click", true, true); pom.dispatchEvent(event); } else { @@ -28,18 +219,14 @@ const ExportJson = (props: any) => { } return ( - <> - - + ); }; -export default ExportJson; +export default ExportSvg; diff --git a/editor-app/components/Footer.tsx b/editor-app/components/Footer.tsx index 5c5003b..8ca0f1c 100644 --- a/editor-app/components/Footer.tsx +++ b/editor-app/components/Footer.tsx @@ -1,56 +1,98 @@ +// filepath: c:\Users\me1meg\Documents\4d-activity-editor\editor-app\components\Footer.tsx import Link from "next/link"; +import pkg from "../package.json"; function Footer() { - const style = {}; + const year = new Date().getFullYear(); + return ( - <> -
-
-

-

+
+
+
+ {/* Left - Links */} +
+
More
+
    +
  • + + Get in touch + +
  • +
  • + + Give feedback + +
  • +
+
+ + {/* Center - Copyright */} +
+
+
{year} Apollo Protocol Activity Diagram Editor
+
Created by AMRC in collaboration with CIS
+
Version: v{pkg.version}
+
+
-
-
-
-
More
-
    -
  • Get in touch
  • -
  • Give feedback
  • -
- -
-
-

2023 Apollo Protocol Activity Diagram Editor

Created by AMRC in collaboration with CIS

-

-
-
- -
-
- - ... - - -
-
- ... -
- -
+ {/* Right - All Logos on same row */} +
+
+ + + AMRC + + -
-
- Funded by ... -
-
- -
-
-
- -
-
- + + CIS + + +
+ + Funded by + + + Innovate UK + +
+ + + + + ); } + export default Footer; diff --git a/editor-app/components/HideIndividuals.tsx b/editor-app/components/HideIndividuals.tsx new file mode 100644 index 0000000..d60b7ef --- /dev/null +++ b/editor-app/components/HideIndividuals.tsx @@ -0,0 +1,84 @@ +import React, { Dispatch, SetStateAction } from "react"; +import Button from "react-bootstrap/Button"; +import OverlayTrigger from "react-bootstrap/OverlayTrigger"; +import Tooltip from "react-bootstrap/Tooltip"; +import { Model } from "@/lib/Model"; +import { Activity, EntityType } from "@/lib/Schema"; + +interface Props { + compactMode: boolean; + setCompactMode: Dispatch>; + dataset: Model; + activitiesInView: Activity[]; +} + +const HideIndividuals = ({ + compactMode, + setCompactMode, + dataset, + activitiesInView, +}: Props) => { + // Find all participating individual IDs + const participating = new Set(); + activitiesInView.forEach((a) => + a.participations.forEach((p: any) => participating.add(p.individualId)) + ); + + // Check if there are entities that would be hidden + const hasHideableEntities = (() => { + const displayIndividuals = dataset.getDisplayIndividuals(); + + for (const ind of displayIndividuals) { + const entityType = ind.entityType ?? EntityType.Individual; + const isVirtualRow = ind.id.includes("__installed_in__"); + + // Top-level SC/IC with installations - always hideable + if ( + (entityType === EntityType.SystemComponent || + entityType === EntityType.InstalledComponent) && + !isVirtualRow && + ind.installations && + ind.installations.length > 0 + ) { + return true; + } + + // Virtual row not participating - hideable + if (isVirtualRow && !participating.has(ind.id)) { + return true; + } + + // Regular entity not participating - hideable + if (!isVirtualRow && !participating.has(ind.id)) { + return true; + } + } + return false; + })(); + + if (!hasHideableEntities) return null; + + const tooltip = compactMode ? ( + + Show all entities including those with no activity. + + ) : ( + + Hide entities with no activity. Top-level component definitions are always + hidden when they have installations. + + ); + + return ( + + + + ); +}; + +export default HideIndividuals; diff --git a/editor-app/components/NavBar.tsx b/editor-app/components/NavBar.tsx index 4237cc4..883ce19 100644 --- a/editor-app/components/NavBar.tsx +++ b/editor-app/components/NavBar.tsx @@ -1,5 +1,6 @@ import React from "react"; import Link from "next/link"; +import { useRouter } from "next/router"; import Container from "react-bootstrap/Container"; import Nav from "react-bootstrap/Nav"; import Navbar from "react-bootstrap/Navbar"; @@ -7,59 +8,86 @@ import NavDropdown from "react-bootstrap/NavDropdown"; interface NavItemProps { href: string; - /* This disallows creating nav items containing anything but plain - * strings. But without this I can't see how to get tsc to let me pass - * the children into React.createElement below. */ children: string; linkType?: React.FunctionComponent; } function NavItem(props: NavItemProps) { const { href, children } = props; - const linkType = props.linkType ?? Nav.Link; + const router = useRouter(); + const isActive = router.pathname === "/" + href || router.pathname === href; + return ( - - {React.createElement(linkType, { as: "span" }, children)} + + {children} ); } -/* - - - First Topic - - - Second topic - - - Second Topic - - -*/ - function CollapsibleExample() { + const router = useRouter(); + const isActivityModellingActive = [ + "/intro", + "/crane", + "/management", + ].includes(router.pathname); + return ( - - - - + + + + Apollo Protocol + -