mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
## Folder & Project
- Renamed `semantica-explorer/` → `explorer/` (cleaner path)
- Browser tab title: `Semantica Knowledge Explorer`
- Brand pill: `SEM` → `SKE` (tooltip: Semantica Knowledge Explorer)
- Nav rail label: `Explore` → `Knowledge Explorer`
- package.json name: `semantica-knowledge-explorer`
- Downgraded Vite 8 → Vite 5 for Node v20.17.0 compatibility
## App Shell
- Dynamic per-workspace kicker labels replacing static "Workspace" pill:
Graph Studio · Vocabulary Browser · Reasoning Engine · SPARQL Query ·
Decision Intelligence · Knowledge Audit · Graph Governance
## Enrich Workspace — 2 new tabs
### Entity Resolution tab
- Similarity threshold slider (0.50–0.99)
- Run Dedup Scan → POST /api/enrich/dedup
- Flagged pairs list with colour-coded score bars (red/amber/green)
- Expandable inline diff: primary vs duplicate side-by-side
- One-click Merge → POST /api/enrich/merge with logEvent dispatch
- Dismiss per pair; Clear all button
- Merge history sidebar pulled live from Registry store
### Registry tab (Document Registry)
- Live chronological audit log of all KG mutations in-session
- Colour-coded op-type badges: IMPORT · MERGE · ADD NODE · ADD EDGE ·
INFER · DELETE · EXPORT · VOCAB
- Filter pills to narrow by operation type
- Expandable JSON detail rows per entry
- Clear log button
- Entirely client-side via registryStore (no backend needed)
## Manage Workspace — 2 new tabs
### KG Overview tab
- Stats chips: total nodes, edges, graph density
- Node type breakdown bar chart (up to 8 types, colour-coded)
- Edge type breakdown bar chart from /api/graph/stats
- Top-10 most connected nodes ranked by degree
- Skeleton loading states + Refresh button
### Ontology Summary tab
- Read-only SKOS scheme tree (scheme → top concepts → narrower)
- Concept detail panel: labels, notation, description, narrower nav
- "Open Full Browser" button deep-links to Vocabulary Browser tab
## Decision Workspace polish
- CausalFlowDiagram: vertical node cards connected by relationship pills
- Outcome badges: colour-coded (green=approved, red=rejected, amber=deferred)
- Live filter input across decision ID, category, and outcome
- Animated skeleton loading while list fetches
## Graph Inspector polish
- PathFlowViz: clickable node chips connected by edge-type labels;
clicking a chip focuses that node in the canvas
- Link Prediction button shows spinner while computing
- Empty states for path trace and candidate links sections
## Registry dispatch — WebSocket
- ADD_NODE events → logEvent("add-node", …) in GraphWorkspace WS handler
- ADD_EDGE events → logEvent("add-edge", …) in GraphWorkspace WS handler
- Import, Export, Merge already dispatched logEvent on API response
## Graph visibility overhaul
### Edge colours (were nearly transparent, now clearly visible)
- edgeBackbone: rgba(…, 0.04) → rgba(…, 0.38)
- edgeStructure: rgba(…, 0.009) → rgba(…, 0.28)
- edgeInspection: rgba(…, 0.026) → rgba(…, 0.48)
- Muted edges: 0.009–0.02 → 0.12–0.26
- Focus edges: 0.16 → 0.42
### Edge sizes
- default minSize: 0.18 → 0.9 (always at least 1 pixel wide)
- path minSize: 1.8 → 2.4
- inactive/muted: hide:true → hide:false (dimmed not hidden)
### Node sizes
- default sizeMultiplier: 0.72 → 0.92
- default minSize: 0.68 → 3.5 (visible at all zoom levels)
- overview nodeScale: 0.66 → 0.88
- nodeTintMix (colour): 0.03 → 0.14
- nodeCoreMix (brightness): 0.52 → 0.72
### Label budget
- overview: 10 → 28 labels
- structure: 36 → 60 labels
- inspection: 80 → 120 labels
### Sigma settings
- renderEdgeLabels: false → true (relationship type on every edge)
- edgeLabelSize: — → 10
- labelRenderedSizeThreshold: 4 → 2
- labelDensity: 0.86 → 1.1
- hideLabelsOnMove: true → false (labels stay visible while panning)
- hideEdgesOnMove: true → false (edges stay visible while panning)
- minCameraRatio: — → 0.04 (prevents zooming inside a node)
- maxCameraRatio: — → 8 (graph stays visible when zoomed out)
### Zoom controls
- Added Zoom In (+) and Zoom Out (−) buttons to graph toolbar
- Smooth animated zoom via camera.animatedZoom / animatedUnzoom (200ms)
- Mouse scroll wheel clamped between minCameraRatio and maxCameraRatio
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
199 lines
8.4 KiB
TypeScript
199 lines
8.4 KiB
TypeScript
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
|
import { DataSet } from "vis-data";
|
|
import { Timeline } from "vis-timeline";
|
|
import type { TimelineOptions } from "vis-timeline";
|
|
import "vis-timeline/styles/vis-timeline-graph2d.css";
|
|
|
|
export interface TimelinePanelProps {
|
|
onTimeChange: (time: Date) => void;
|
|
minDate?: string;
|
|
maxDate?: string;
|
|
}
|
|
|
|
const DEFAULT_MIN_DATE = new Date("1970-01-01T00:00:00Z");
|
|
const DEFAULT_MAX_DATE = new Date("2030-01-01T00:00:00Z");
|
|
const PLAYHEAD_ID = "playhead";
|
|
const PLAY_INTERVAL_MS = 500;
|
|
const PLAY_STEP_MONTHS = 6;
|
|
|
|
const VIS_OVERRIDE_CSS = `
|
|
.sem-timeline-wrap .vis-timeline { border: none !important; background: transparent !important; overflow: visible !important; }
|
|
.sem-timeline-wrap .vis-panel.vis-background, .sem-timeline-wrap .vis-panel.vis-center { background: transparent !important; }
|
|
.sem-timeline-wrap .vis-panel { border-color: rgba(88, 166, 255, 0.15) !important; }
|
|
.sem-timeline-wrap .vis-time-axis .vis-text {
|
|
color: #8b949e !important;
|
|
font-size: 11px !important;
|
|
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
|
|
padding-top: 3px !important;
|
|
}
|
|
.sem-timeline-wrap .vis-time-axis .vis-text.vis-major {
|
|
color: #c9d1d9 !important;
|
|
font-weight: 700 !important;
|
|
font-size: 12px !important;
|
|
}
|
|
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-minor { border-color: rgba(88, 166, 255, 0.07) !important; }
|
|
.sem-timeline-wrap .vis-time-axis .vis-grid.vis-major { border-color: rgba(88, 166, 255, 0.18) !important; }
|
|
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} {
|
|
background: rgba(88, 166, 255, 0.15) !important;
|
|
width: 2px !important;
|
|
cursor: ew-resize !important;
|
|
z-index: 5 !important;
|
|
}
|
|
.sem-timeline-wrap .vis-custom-time.${PLAYHEAD_ID} > .vis-custom-time-marker {
|
|
background: #58a6ff !important;
|
|
color: #0d1117 !important;
|
|
font-size: 10px !important;
|
|
font-weight: 700 !important;
|
|
border-radius: 3px !important;
|
|
padding: 1px 5px !important;
|
|
white-space: nowrap !important;
|
|
box-shadow: 0 0 8px rgba(88, 166, 255, 0.7) !important;
|
|
}
|
|
.sem-timeline-wrap .vis-current-time { display: none !important; }
|
|
.sem-timeline-wrap .vis-panel.vis-left { display: none !important; }
|
|
`;
|
|
|
|
function safeDate(value: string | undefined, fallback: Date): Date {
|
|
if (!value) return fallback;
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
|
}
|
|
|
|
function formatPlayheadLabel(value: Date): string {
|
|
return `${value.getFullYear()}/${String(value.getMonth() + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
export function TimelinePanel({ onTimeChange, minDate, maxDate }: TimelinePanelProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const timelineRef = useRef<Timeline | null>(null);
|
|
const playheadRef = useRef<Date>(DEFAULT_MIN_DATE);
|
|
const playIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [displayDate, setDisplayDate] = useState(formatPlayheadLabel(DEFAULT_MIN_DATE));
|
|
|
|
const minBound = useMemo(() => safeDate(minDate, DEFAULT_MIN_DATE), [minDate]);
|
|
const maxBound = useMemo(() => safeDate(maxDate, DEFAULT_MAX_DATE), [maxDate]);
|
|
const defaultTime = useMemo(() => new Date(Math.round((minBound.getTime() + maxBound.getTime()) / 2)), [maxBound, minBound]);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
|
|
const timeline = timelineRef.current;
|
|
if (!timeline) {
|
|
const items = new DataSet([]);
|
|
const options: TimelineOptions = {
|
|
height: "100%",
|
|
min: minBound,
|
|
max: maxBound,
|
|
start: minBound,
|
|
end: maxBound,
|
|
showCurrentTime: false,
|
|
zoomable: true,
|
|
moveable: true,
|
|
zoomMin: 1000 * 60 * 60 * 24 * 365,
|
|
zoomMax: 1000 * 60 * 60 * 24 * 365 * 80,
|
|
showMajorLabels: true,
|
|
showMinorLabels: true,
|
|
timeAxis: { scale: "year", step: 5 },
|
|
format: { minorLabels: { year: "YYYY" }, majorLabels: { year: "YYYY" } },
|
|
orientation: { axis: "bottom" },
|
|
margin: { item: 0, axis: 0 },
|
|
selectable: false,
|
|
stack: false,
|
|
} as TimelineOptions;
|
|
|
|
const nextTimeline = new Timeline(containerRef.current, items, options);
|
|
timelineRef.current = nextTimeline;
|
|
playheadRef.current = defaultTime;
|
|
nextTimeline.addCustomTime(defaultTime, PLAYHEAD_ID);
|
|
nextTimeline.on("timechange", (props: { id: string; time: Date }) => {
|
|
if (props.id !== PLAYHEAD_ID) return;
|
|
playheadRef.current = props.time;
|
|
nextTimeline.setCustomTime(props.time, PLAYHEAD_ID);
|
|
onTimeChange(props.time);
|
|
setDisplayDate(formatPlayheadLabel(props.time));
|
|
});
|
|
onTimeChange(defaultTime);
|
|
setDisplayDate(formatPlayheadLabel(defaultTime));
|
|
return () => {
|
|
nextTimeline.destroy();
|
|
timelineRef.current = null;
|
|
};
|
|
}
|
|
|
|
timeline.setOptions({ min: minBound, max: maxBound, start: minBound, end: maxBound });
|
|
playheadRef.current = defaultTime;
|
|
timeline.setCustomTime(defaultTime, PLAYHEAD_ID);
|
|
onTimeChange(defaultTime);
|
|
setDisplayDate(formatPlayheadLabel(defaultTime));
|
|
}, [defaultTime, maxBound, minBound, onTimeChange]);
|
|
|
|
const startPlay = useCallback(() => {
|
|
if (playIntervalRef.current) return;
|
|
playIntervalRef.current = setInterval(() => {
|
|
const timeline = timelineRef.current;
|
|
if (!timeline) return;
|
|
const next = new Date(playheadRef.current);
|
|
next.setMonth(next.getMonth() + PLAY_STEP_MONTHS);
|
|
if (next >= maxBound) {
|
|
next.setTime(minBound.getTime());
|
|
}
|
|
playheadRef.current = next;
|
|
timeline.setCustomTime(next, PLAYHEAD_ID);
|
|
onTimeChange(next);
|
|
setDisplayDate(formatPlayheadLabel(next));
|
|
}, PLAY_INTERVAL_MS);
|
|
}, [maxBound, minBound, onTimeChange]);
|
|
|
|
const stopPlay = useCallback(() => {
|
|
if (playIntervalRef.current) {
|
|
clearInterval(playIntervalRef.current);
|
|
playIntervalRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const togglePlay = useCallback(() => {
|
|
setIsPlaying((previous) => {
|
|
if (previous) {
|
|
stopPlay();
|
|
return false;
|
|
}
|
|
startPlay();
|
|
return true;
|
|
});
|
|
}, [startPlay, stopPlay]);
|
|
|
|
useEffect(() => () => stopPlay(), [stopPlay]);
|
|
|
|
return (
|
|
<div style={{ position: "relative", width: "100%", height: "90px", borderTop: "1px solid rgba(88, 166, 255, 0.2)", background: "rgba(1, 4, 9, 0.88)", backdropFilter: "blur(16px)", WebkitBackdropFilter: "blur(16px)", display: "flex", alignItems: "stretch", flexShrink: 0 }}>
|
|
<style>{VIS_OVERRIDE_CSS}</style>
|
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 4, padding: "0 16px", borderRight: "1px solid rgba(88, 166, 255, 0.15)", minWidth: 80, flexShrink: 0 }}>
|
|
<button
|
|
id="temporal-play-btn"
|
|
onClick={togglePlay}
|
|
title={isPlaying ? "Pause Evolution" : "Play Evolution"}
|
|
style={{ width: 34, height: 34, borderRadius: "50%", border: `1.5px solid ${isPlaying ? "#58a6ff" : "rgba(88, 166, 255, 0.35)"}`, background: isPlaying ? "rgba(88, 166, 255, 0.2)" : "rgba(88, 166, 255, 0.06)", color: "#58a6ff", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s", boxShadow: isPlaying ? "0 0 10px rgba(88, 166, 255, 0.4)" : "none" }}
|
|
>
|
|
{isPlaying ? (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" /><rect x="14" y="4" width="4" height="16" /></svg>
|
|
) : (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21" /></svg>
|
|
)}
|
|
</button>
|
|
<span style={{ fontSize: 10, color: isPlaying ? "#58a6ff" : "#8b949e", fontFamily: "monospace", letterSpacing: "0.04em", transition: "color 0.2s" }}>
|
|
{displayDate}
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ position: "absolute", top: 5, left: 100, fontSize: 10, fontWeight: 600, letterSpacing: "0.1em", color: "rgba(88, 166, 255, 0.55)", textTransform: "uppercase", pointerEvents: "none", zIndex: 2 }}>
|
|
Temporal Scrubber · {minBound.getFullYear()}-{maxBound.getFullYear()}
|
|
</div>
|
|
|
|
<div className="sem-timeline-wrap" style={{ flex: 1, overflow: "hidden", position: "relative" }}>
|
|
<div ref={containerRef} style={{ width: "100%", height: "100%", position: "relative" }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|