R: Rework TGFlow rerenders

This commit is contained in:
Ivan 2025-02-25 21:43:38 +03:00
parent fcdaa81836
commit 0fec8f9d61
12 changed files with 198 additions and 239 deletions

View File

@ -4,8 +4,7 @@ import { Overlay } from '@/components/Container';
import { SelectSingle } from '@/components/Input'; import { SelectSingle } from '@/components/Input';
import { mapLabelColoring } from '../../../labels'; import { mapLabelColoring } from '../../../labels';
import { type IRSForm } from '../../../models/rsform'; import { type GraphColoring, useTermGraphStore } from '../../../stores/termGraph';
import { type GraphColoring } from '../../../stores/termGraph';
import { SchemasGuide } from './SchemasGuide'; import { SchemasGuide } from './SchemasGuide';
@ -15,19 +14,16 @@ import { SchemasGuide } from './SchemasGuide';
const SelectorGraphColoring: { value: GraphColoring; label: string }[] = // const SelectorGraphColoring: { value: GraphColoring; label: string }[] = //
[...mapLabelColoring.entries()].map(item => ({ value: item[0], label: item[1] })); [...mapLabelColoring.entries()].map(item => ({ value: item[0], label: item[1] }));
interface GraphSelectorsProps { export function GraphSelectors() {
schema: IRSForm; const coloring = useTermGraphStore(state => state.coloring);
coloring: GraphColoring; const setColoring = useTermGraphStore(state => state.setColoring);
onChangeColoring: (newValue: GraphColoring) => void;
}
export function GraphSelectors({ schema, coloring, onChangeColoring }: GraphSelectorsProps) {
return ( return (
<div className='border rounded-b-none select-none clr-input rounded-t-md pointer-events-auto'> <div className='border rounded-b-none select-none clr-input rounded-t-md pointer-events-auto'>
<Overlay position='right-[2.5rem] top-[0.25rem]'> <Overlay position='right-[2.5rem] top-[0.25rem]'>
{coloring === 'status' ? <BadgeHelp topic={HelpTopic.UI_CST_STATUS} className='min-w-[25rem]' /> : null} {coloring === 'status' ? <BadgeHelp topic={HelpTopic.UI_CST_STATUS} className='min-w-[25rem]' /> : null}
{coloring === 'type' ? <BadgeHelp topic={HelpTopic.UI_CST_CLASS} className='min-w-[25rem]' /> : null} {coloring === 'type' ? <BadgeHelp topic={HelpTopic.UI_CST_CLASS} className='min-w-[25rem]' /> : null}
{coloring === 'schemas' ? <SchemasGuide schema={schema} /> : null} {coloring === 'schemas' ? <SchemasGuide /> : null}
</Overlay> </Overlay>
<SelectSingle <SelectSingle
noBorder noBorder
@ -35,7 +31,7 @@ export function GraphSelectors({ schema, coloring, onChangeColoring }: GraphSele
options={SelectorGraphColoring} options={SelectorGraphColoring}
isSearchable={false} isSearchable={false}
value={coloring ? { value: coloring, label: mapLabelColoring.get(coloring) } : null} value={coloring ? { value: coloring, label: mapLabelColoring.get(coloring) } : null}
onChange={data => onChangeColoring(data?.value ?? SelectorGraphColoring[0].value)} onChange={data => setColoring(data?.value ?? SelectorGraphColoring[0].value)}
/> />
</div> </div>
); );

View File

@ -5,14 +5,11 @@ import { IconHelp } from '@/components/Icons';
import { globalIDs, prefixes } from '@/utils/constants'; import { globalIDs, prefixes } from '@/utils/constants';
import { colorBgSchemas } from '../../../colors'; import { colorBgSchemas } from '../../../colors';
import { type IRSForm } from '../../../models/rsform'; import { useRSEdit } from '../RSEditContext';
interface SchemasGuideProps { export function SchemasGuide() {
schema: IRSForm;
}
export function SchemasGuide({ schema }: SchemasGuideProps) {
const { items: libraryItems } = useLibrary(); const { items: libraryItems } = useLibrary();
const { schema } = useRSEdit();
const schemas = (() => { const schemas = (() => {
const processed = new Set<number>(); const processed = new Set<number>();

View File

@ -1,18 +0,0 @@
import { Overlay } from '@/components/Container';
interface SelectedCounterProps {
totalCount: number;
selectedCount: number;
position?: string;
}
export function SelectedCounter({ totalCount, selectedCount, position = 'top-0 left-0' }: SelectedCounterProps) {
if (selectedCount === 0) {
return null;
}
return (
<Overlay position={`px-2 ${position}`} className='select-none whitespace-nowrap cc-blur rounded-xl'>
Выбор {selectedCount} из {totalCount}
</Overlay>
);
}

View File

@ -15,30 +15,27 @@ import {
import { Overlay } from '@/components/Container'; import { Overlay } from '@/components/Container';
import { useMainHeight } from '@/stores/appLayout'; import { useMainHeight } from '@/stores/appLayout';
import { APP_COLORS } from '@/styling/colors';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { CstType } from '../../../backend/types';
import { useMutatingRSForm } from '../../../backend/useMutatingRSForm'; import { useMutatingRSForm } from '../../../backend/useMutatingRSForm';
import { colorBgGraphNode } from '../../../colors';
import { ToolbarGraphSelection } from '../../../components/ToolbarGraphSelection'; import { ToolbarGraphSelection } from '../../../components/ToolbarGraphSelection';
import { type IConstituenta, type IRSForm } from '../../../models/rsform'; import { type IConstituenta } from '../../../models/rsform';
import { isBasicConcept } from '../../../models/rsformAPI'; import { isBasicConcept } from '../../../models/rsformAPI';
import { type GraphFilterParams, useTermGraphStore } from '../../../stores/termGraph'; import { useTermGraphStore } from '../../../stores/termGraph';
import { useRSEdit } from '../RSEditContext'; import { useRSEdit } from '../RSEditContext';
import { TGEdgeTypes } from './graph/TGEdgeTypes'; import { TGEdgeTypes } from './graph/TGEdgeTypes';
import { applyLayout } from './graph/TGLayout'; import { applyLayout } from './graph/TGLayout';
import { type TGNodeData } from './graph/TGNode';
import { TGNodeTypes } from './graph/TGNodeTypes'; import { TGNodeTypes } from './graph/TGNodeTypes';
import { GraphSelectors } from './GraphSelectors'; import { GraphSelectors } from './GraphSelectors';
import { SelectedCounter } from './SelectedCounter';
import { ToolbarFocusedCst } from './ToolbarFocusedCst'; import { ToolbarFocusedCst } from './ToolbarFocusedCst';
import { ToolbarTermGraph } from './ToolbarTermGraph'; import { ToolbarTermGraph } from './ToolbarTermGraph';
import { useFilteredGraph } from './useFilteredGraph';
import { ViewHidden } from './ViewHidden'; import { ViewHidden } from './ViewHidden';
export const ZOOM_MAX = 3; export const ZOOM_MAX = 3;
export const ZOOM_MIN = 0.25; export const ZOOM_MIN = 0.25;
export const VIEW_PADDING = 0.3;
export function TGFlow() { export function TGFlow() {
const mainHeight = useMainHeight(); const mainHeight = useMainHeight();
@ -51,50 +48,38 @@ export function TGFlow() {
schema, schema,
selected, selected,
setSelected, setSelected,
navigateCst,
toggleSelect,
canDeleteSelected, canDeleteSelected,
promptDeleteCst promptDeleteCst,
focusCst,
setFocus,
deselectAll
} = useRSEdit(); } = useRSEdit();
const filter = useTermGraphStore(state => state.filter); const [needReset, setNeedReset] = useState(true);
const coloring = useTermGraphStore(state => state.coloring);
const setColoring = useTermGraphStore(state => state.setColoring);
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges] = useEdgesState([]); const [edges, setEdges] = useEdgesState([]);
const [focusCst, setFocusCst] = useState<IConstituenta | null>(null); const filter = useTermGraphStore(state => state.filter);
const filteredGraph = produceFilteredGraph(schema, filter, focusCst); const { filteredGraph, hidden } = useFilteredGraph();
const [hidden, setHidden] = useState<number[]>([]);
const [needReset, setNeedReset] = useState(true);
function onSelectionChange({ nodes }: { nodes: Node[] }) { function onSelectionChange({ nodes }: { nodes: Node[] }) {
const ids = nodes.map(node => Number(node.id)); const ids = nodes.map(node => Number(node.id));
if (ids.length === 0) { if (ids.length === 0) {
setSelected([]); deselectAll();
} else { } else {
setSelected(prev => [...prev.filter(nodeID => !filteredGraph.hasNode(nodeID)), ...ids]); setSelected(prev => [...prev.filter(nodeID => !filteredGraph.hasNode(nodeID)), ...ids]);
} }
} }
useOnSelectionChange({ useOnSelectionChange({
onChange: onSelectionChange onChange: onSelectionChange
}); });
useEffect(() => { useEffect(() => {
const newDismissed: number[] = []; setNeedReset(true);
schema.items.forEach(cst => { }, [schema, filter, focusCst]);
if (!filteredGraph.nodes.has(cst.id)) {
newDismissed.push(cst.id);
}
});
setHidden(newDismissed);
}, [schema, filteredGraph]);
const resetNodes = useCallback(() => { const resetNodes = useCallback(() => {
const newNodes: Node<TGNodeData>[] = []; const newNodes: Node<IConstituenta>[] = [];
filteredGraph.nodes.forEach(node => { filteredGraph.nodes.forEach(node => {
const cst = schema.cstByID.get(node.id); const cst = schema.cstByID.get(node.id);
if (cst) { if (cst) {
@ -103,10 +88,7 @@ export function TGFlow() {
type: 'concept', type: 'concept',
selected: selected.includes(node.id), selected: selected.includes(node.id),
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: cst
fill: focusCst === cst ? APP_COLORS.bgPurple : colorBgGraphNode(cst, coloring),
cst: cst
}
}); });
} }
}); });
@ -137,11 +119,11 @@ export function TGFlow() {
setNodes(newNodes); setNodes(newNodes);
setEdges(newEdges); setEdges(newEdges);
}, [schema, filteredGraph, setNodes, setEdges, filter.noText, selected, focusCst, coloring]);
useEffect(() => { setTimeout(() => {
setNeedReset(true); fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING });
}, [schema, focusCst, coloring, filter]); }, PARAMETER.minimalTimeout);
}, [schema, filteredGraph, setNodes, setEdges, filter.noText, selected, fitView]);
useEffect(() => { useEffect(() => {
if (!needReset || !viewportInitialized) { if (!needReset || !viewportInitialized) {
@ -163,8 +145,7 @@ export function TGFlow() {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handleSetFocus(null); setFocus(null);
handleSetSelected([]);
return; return;
} }
if (!isContentEditable) { if (!isContentEditable) {
@ -180,36 +161,11 @@ export function TGFlow() {
} }
} }
function handleSetFocus(cstID: number | null) {
if (cstID === null) {
setFocusCst(null);
} else {
const target = schema.cstByID.get(cstID) ?? null;
setFocusCst(prev => (prev === target ? null : target));
}
setSelected([]);
setTimeout(() => {
fitView({ duration: PARAMETER.zoomDuration });
}, PARAMETER.minimalTimeout);
}
function handleNodeContextMenu(event: React.MouseEvent, cstID: number) {
event.preventDefault();
event.stopPropagation();
handleSetFocus(cstID);
}
function handleNodeDoubleClick(event: React.MouseEvent, cstID: number) {
event.preventDefault();
event.stopPropagation();
navigateCst(cstID);
}
return ( return (
<> <>
<Overlay position='cc-tab-tools' className='flex flex-col items-center rounded-b-2xl cc-blur'> <Overlay position='cc-tab-tools' className='flex flex-col items-center rounded-b-2xl cc-blur'>
<ToolbarTermGraph /> <ToolbarTermGraph />
{focusCst ? <ToolbarFocusedCst focusedCst={focusCst} onResetFocus={() => handleSetFocus(null)} /> : null} <ToolbarFocusedCst />
{!focusCst ? ( {!focusCst ? (
<ToolbarGraphSelection <ToolbarGraphSelection
graph={schema.graph} graph={schema.graph}
@ -225,24 +181,15 @@ export function TGFlow() {
</Overlay> </Overlay>
<div className='cc-fade-in' tabIndex={-1} onKeyDown={handleKeyDown}> <div className='cc-fade-in' tabIndex={-1} onKeyDown={handleKeyDown}>
<SelectedCounter <Overlay
totalCount={schema.stats?.count_all ?? 0} position='top-[4.4rem] sm:top-[4.1rem] left-[0.5rem] sm:left-[0.65rem] w-[13.5rem]'
selectedCount={selected.length} className='flex flex-col pointer-events-none'
position='top-[4.4rem] sm:top-[4.1rem] left-[0.5rem] sm:left-[0.65rem]' >
/> <div className='px-2 pb-1 select-none whitespace-nowrap cc-blur rounded-xl'>
Выбор {selected.length} из {schema.stats?.count_all ?? 0}
<Overlay position='top-[6.15rem] sm:top-[5.9rem] left-0' className='flex gap-1 pointer-events-none'>
<div className='flex flex-col ml-2 w-[13.5rem]'>
<GraphSelectors schema={schema} coloring={coloring} onChangeColoring={setColoring} />
<ViewHidden
items={hidden}
selected={selected}
schema={schema}
coloringScheme={coloring}
toggleSelection={toggleSelect}
setFocus={handleSetFocus}
/>
</div> </div>
<GraphSelectors />
<ViewHidden items={hidden} />
</Overlay> </Overlay>
<div className='relative outline-hidden w-[100dvw]' style={{ height: mainHeight }}> <div className='relative outline-hidden w-[100dvw]' style={{ height: mainHeight }}>
@ -258,71 +205,10 @@ export function TGFlow() {
edgeTypes={TGEdgeTypes} edgeTypes={TGEdgeTypes}
maxZoom={ZOOM_MAX} maxZoom={ZOOM_MAX}
minZoom={ZOOM_MIN} minZoom={ZOOM_MIN}
onNodeDoubleClick={(event, node) => handleNodeDoubleClick(event, Number(node.id))} onContextMenu={event => event.preventDefault()}
onNodeContextMenu={(event, node) => handleNodeContextMenu(event, Number(node.id))}
/> />
</div> </div>
</div> </div>
</> </>
); );
} }
// ====== Internals =========
function produceFilteredGraph(schema: IRSForm, params: GraphFilterParams, focusCst: IConstituenta | null) {
const filtered = schema.graph.clone();
const allowedTypes: CstType[] = (() => {
const result: CstType[] = [];
if (params.allowBase) result.push(CstType.BASE);
if (params.allowStruct) result.push(CstType.STRUCTURED);
if (params.allowTerm) result.push(CstType.TERM);
if (params.allowAxiom) result.push(CstType.AXIOM);
if (params.allowFunction) result.push(CstType.FUNCTION);
if (params.allowPredicate) result.push(CstType.PREDICATE);
if (params.allowConstant) result.push(CstType.CONSTANT);
if (params.allowTheorem) result.push(CstType.THEOREM);
return result;
})();
if (params.noHermits) {
filtered.removeIsolated();
}
if (params.noTemplates) {
schema.items.forEach(cst => {
if (cst !== focusCst && cst.is_template) {
filtered.foldNode(cst.id);
}
});
}
if (allowedTypes.length < Object.values(CstType).length) {
schema.items.forEach(cst => {
if (cst !== focusCst && !allowedTypes.includes(cst.cst_type)) {
filtered.foldNode(cst.id);
}
});
}
if (!focusCst && params.foldDerived) {
schema.items.forEach(cst => {
if (cst.spawner) {
filtered.foldNode(cst.id);
}
});
}
if (focusCst) {
const includes: number[] = [
focusCst.id,
...focusCst.spawn,
...(params.focusShowInputs ? schema.graph.expandInputs([focusCst.id]) : []),
...(params.focusShowOutputs ? schema.graph.expandOutputs([focusCst.id]) : [])
];
schema.items.forEach(cst => {
if (!includes.includes(cst.id)) {
filtered.foldNode(cst.id);
}
});
}
if (params.noTransitive) {
filtered.transitiveReduction();
}
return filtered;
}

View File

@ -6,23 +6,16 @@ import { MiniButton } from '@/components/Control';
import { IconGraphInputs, IconGraphOutputs, IconReset } from '@/components/Icons'; import { IconGraphInputs, IconGraphOutputs, IconReset } from '@/components/Icons';
import { APP_COLORS } from '@/styling/colors'; import { APP_COLORS } from '@/styling/colors';
import { type IConstituenta } from '../../../models/rsform';
import { useRSEdit } from '../RSEditContext'; import { useRSEdit } from '../RSEditContext';
interface ToolbarFocusedCstProps { export function ToolbarFocusedCst() {
focusedCst: IConstituenta; const { setFocus, focusCst } = useRSEdit();
onResetFocus: () => void;
}
export function ToolbarFocusedCst({ focusedCst, onResetFocus }: ToolbarFocusedCstProps) {
const { deselectAll } = useRSEdit();
const filter = useTermGraphStore(state => state.filter); const filter = useTermGraphStore(state => state.filter);
const setFilter = useTermGraphStore(state => state.setFilter); const setFilter = useTermGraphStore(state => state.setFilter);
function resetSelection() { function resetSelection() {
onResetFocus(); setFocus(null);
deselectAll();
} }
function handleShowInputs() { function handleShowInputs() {
@ -39,11 +32,15 @@ export function ToolbarFocusedCst({ focusedCst, onResetFocus }: ToolbarFocusedCs
}); });
} }
if (!focusCst) {
return null;
}
return ( return (
<div className='items-center cc-icons'> <div className='items-center cc-icons'>
<div className='w-[7.8rem] text-right select-none' style={{ color: APP_COLORS.fgPurple }}> <div className='w-[7.8rem] text-right select-none' style={{ color: APP_COLORS.fgPurple }}>
Фокус Фокус
<b className='px-1'> {focusedCst.alias} </b> <b className='px-1'> {focusCst.alias} </b>
</div> </div>
<MiniButton <MiniButton
titleHtml='Сбросить фокус' titleHtml='Сбросить фокус'

View File

@ -1,5 +1,5 @@
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { getNodesBounds, getViewportForBounds, useNodes, useReactFlow } from 'reactflow'; import { getNodesBounds, getViewportForBounds, useReactFlow } from 'reactflow';
import clsx from 'clsx'; import clsx from 'clsx';
import { toPng } from 'html-to-image'; import { toPng } from 'html-to-image';
@ -30,7 +30,7 @@ import { errorMsg } from '@/utils/labels';
import { useMutatingRSForm } from '../../../backend/useMutatingRSForm'; import { useMutatingRSForm } from '../../../backend/useMutatingRSForm';
import { useRSEdit } from '../RSEditContext'; import { useRSEdit } from '../RSEditContext';
import { ZOOM_MAX, ZOOM_MIN } from './TGFlow'; import { VIEW_PADDING, ZOOM_MAX, ZOOM_MIN } from './TGFlow';
export function ToolbarTermGraph() { export function ToolbarTermGraph() {
const isProcessing = useMutatingRSForm(); const isProcessing = useMutatingRSForm();
@ -48,8 +48,8 @@ export function ToolbarTermGraph() {
const showParams = useDialogsStore(state => state.showGraphParams); const showParams = useDialogsStore(state => state.showGraphParams);
const filter = useTermGraphStore(state => state.filter); const filter = useTermGraphStore(state => state.filter);
const setFilter = useTermGraphStore(state => state.setFilter); const setFilter = useTermGraphStore(state => state.setFilter);
const nodes = useNodes();
const flow = useReactFlow(); const { fitView, getNodes } = useReactFlow();
function handleShowTypeGraph() { function handleShowTypeGraph() {
const typeInfo = schema.items.map(item => ({ const typeInfo = schema.items.map(item => ({
@ -88,7 +88,7 @@ export function ToolbarTermGraph() {
const imageWidth = PARAMETER.ossImageWidth; const imageWidth = PARAMETER.ossImageWidth;
const imageHeight = PARAMETER.ossImageHeight; const imageHeight = PARAMETER.ossImageHeight;
const nodesBounds = getNodesBounds(nodes); const nodesBounds = getNodesBounds(getNodes());
const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, ZOOM_MIN, ZOOM_MAX); const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, ZOOM_MIN, ZOOM_MAX);
toPng(canvas, { toPng(canvas, {
backgroundColor: darkMode ? APP_COLORS.bgDefaultDark : APP_COLORS.bgDefaultLight, backgroundColor: darkMode ? APP_COLORS.bgDefaultDark : APP_COLORS.bgDefaultLight,
@ -114,7 +114,7 @@ export function ToolbarTermGraph() {
function handleFitView() { function handleFitView() {
setTimeout(() => { setTimeout(() => {
flow.fitView({ duration: PARAMETER.zoomDuration }); fitView({ duration: PARAMETER.zoomDuration, padding: VIEW_PADDING });
}, PARAMETER.minimalTimeout); }, PARAMETER.minimalTimeout);
} }
@ -123,9 +123,6 @@ export function ToolbarTermGraph() {
...filter, ...filter,
foldDerived: !filter.foldDerived foldDerived: !filter.foldDerived
}); });
setTimeout(() => {
flow.fitView({ duration: PARAMETER.zoomDuration });
}, PARAMETER.graphRefreshDelay);
} }
return ( return (

View File

@ -12,36 +12,35 @@ import { APP_COLORS } from '@/styling/colors';
import { globalIDs, PARAMETER, prefixes } from '@/utils/constants'; import { globalIDs, PARAMETER, prefixes } from '@/utils/constants';
import { colorBgGraphNode } from '../../../colors'; import { colorBgGraphNode } from '../../../colors';
import { type IRSForm } from '../../../models/rsform'; import { type IConstituenta } from '../../../models/rsform';
import { type GraphColoring, useTermGraphStore } from '../../../stores/termGraph'; import { useTermGraphStore } from '../../../stores/termGraph';
import { useRSEdit } from '../RSEditContext'; import { useRSEdit } from '../RSEditContext';
interface ViewHiddenProps { interface ViewHiddenProps {
schema: IRSForm;
items: number[]; items: number[];
selected: number[];
coloringScheme: GraphColoring;
toggleSelection: (cstID: number) => void;
setFocus: (cstID: number) => void;
} }
export function ViewHidden({ items, selected, toggleSelection, setFocus, schema, coloringScheme }: ViewHiddenProps) { export function ViewHidden({ items }: ViewHiddenProps) {
const windowSize = useWindowSize(); const windowSize = useWindowSize();
const localSelected = items.filter(id => selected.includes(id)); const coloring = useTermGraphStore(state => state.coloring);
const { navigateCst, setFocus, schema, selected, toggleSelect } = useRSEdit();
const { navigateCst } = useRSEdit(); const localSelected = items.filter(id => selected.includes(id));
const isFolded = useTermGraphStore(state => state.foldHidden); const isFolded = useTermGraphStore(state => state.foldHidden);
const toggleFolded = useTermGraphStore(state => state.toggleFoldHidden); const toggleFolded = useTermGraphStore(state => state.toggleFoldHidden);
const setActiveCst = useTooltipsStore(state => state.setActiveCst); const setActiveCst = useTooltipsStore(state => state.setActiveCst);
const hiddenHeight = useFitHeight(windowSize.isSmall ? '10.4rem + 2px' : '12.5rem + 2px'); const hiddenHeight = useFitHeight(windowSize.isSmall ? '10.4rem + 2px' : '12.5rem + 2px');
function handleClick(cstID: number, event: React.MouseEvent<Element>) { function handleClick(event: React.MouseEvent<Element>, cstID: number) {
if (event.ctrlKey || event.metaKey) { event.preventDefault();
setFocus(cstID); event.stopPropagation();
} else { toggleSelect(cstID);
toggleSelection(cstID);
} }
function handleContextMenu(event: React.MouseEvent<HTMLElement>, target: IConstituenta) {
event.stopPropagation();
event.preventDefault();
setFocus(target);
} }
if (items.length <= 0) { if (items.length <= 0) {
@ -92,14 +91,13 @@ export function ViewHidden({ items, selected, toggleSelection, setFocus, schema,
> >
{items.map(cstID => { {items.map(cstID => {
const cst = schema.cstByID.get(cstID)!; const cst = schema.cstByID.get(cstID)!;
const id = `${prefixes.cst_hidden_list}${cst.alias}`;
return ( return (
<button <button
key={id} key={`${prefixes.cst_hidden_list}${cst.alias}`}
type='button' type='button'
className='min-w-[3rem] rounded-md text-center select-none' className='min-w-[3rem] rounded-md text-center select-none'
style={{ style={{
backgroundColor: colorBgGraphNode(cst, coloringScheme), backgroundColor: colorBgGraphNode(cst, coloring),
...(localSelected.includes(cstID) ...(localSelected.includes(cstID)
? { ? {
outlineWidth: '2px', outlineWidth: '2px',
@ -108,7 +106,8 @@ export function ViewHidden({ items, selected, toggleSelection, setFocus, schema,
} }
: {}) : {})
}} }}
onClick={event => handleClick(cstID, event)} onClick={event => handleClick(event, cstID)}
onContextMenu={event => handleContextMenu(event, cst)}
onDoubleClick={() => navigateCst(cstID)} onDoubleClick={() => navigateCst(cstID)}
data-tooltip-id={globalIDs.constituenta_tooltip} data-tooltip-id={globalIDs.constituenta_tooltip}
onMouseEnter={() => setActiveCst(cst)} onMouseEnter={() => setActiveCst(cst)}

View File

@ -1,11 +1,11 @@
import { type Edge, type Node } from 'reactflow'; import { type Edge, type Node } from 'reactflow';
import dagre from '@dagrejs/dagre'; import dagre from '@dagrejs/dagre';
import { type IConstituenta } from '@/features/rsform/models/rsform';
import { PARAMETER } from '@/utils/constants'; import { PARAMETER } from '@/utils/constants';
import { type TGNodeData } from './TGNode'; export function applyLayout(nodes: Node<IConstituenta>[], edges: Edge[], subLabels: boolean) {
export function applyLayout(nodes: Node<TGNodeData>[], edges: Edge[], subLabels?: boolean) {
const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); const dagreGraph = new dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ dagreGraph.setGraph({
rankdir: 'TB', rankdir: 'TB',

View File

@ -2,12 +2,14 @@
import { Handle, Position } from 'reactflow'; import { Handle, Position } from 'reactflow';
import { type IConstituenta } from '@/features/rsform/models/rsform';
import { useTermGraphStore } from '@/features/rsform/stores/termGraph';
import { APP_COLORS } from '@/styling/colors'; import { APP_COLORS } from '@/styling/colors';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { colorBgGraphNode } from '../../../../colors';
import { type IConstituenta } from '../../../../models/rsform';
import { useTermGraphStore } from '../../../../stores/termGraph';
import { useRSEdit } from '../../RSEditContext';
const DESCRIPTION_THRESHOLD = 15; const DESCRIPTION_THRESHOLD = 15;
const LABEL_THRESHOLD = 3; const LABEL_THRESHOLD = 3;
@ -15,17 +17,12 @@ const FONT_SIZE_MAX = 14;
const FONT_SIZE_MED = 12; const FONT_SIZE_MED = 12;
const FONT_SIZE_MIN = 10; const FONT_SIZE_MIN = 10;
export interface TGNodeData {
fill: string;
cst: IConstituenta;
}
/** /**
* Represents graph AST node internal data. * Represents graph AST node internal data.
*/ */
interface TGNodeInternal { interface TGNodeInternal {
id: string; id: string;
data: TGNodeData; data: IConstituenta;
selected: boolean; selected: boolean;
dragging: boolean; dragging: boolean;
xPos: number; xPos: number;
@ -33,9 +30,24 @@ interface TGNodeInternal {
} }
export function TGNode(node: TGNodeInternal) { export function TGNode(node: TGNodeInternal) {
const { focusCst, setFocus: setFocusCst, navigateCst } = useRSEdit();
const filter = useTermGraphStore(state => state.filter); const filter = useTermGraphStore(state => state.filter);
const label = node.data.cst.alias; const coloring = useTermGraphStore(state => state.coloring);
const description = !filter.noText ? node.data.cst.term_resolved : '';
const label = node.data.alias;
const description = !filter.noText ? node.data.term_resolved : '';
function handleContextMenu(event: React.MouseEvent<HTMLElement>) {
event.stopPropagation();
event.preventDefault();
setFocusCst(focusCst === node.data ? null : node.data);
}
function handleDoubleClick(event: React.MouseEvent) {
event.preventDefault();
event.stopPropagation();
navigateCst(node.data.id);
}
return ( return (
<> <>
@ -43,11 +55,18 @@ export function TGNode(node: TGNodeInternal) {
<div <div
className='w-full h-full cursor-default flex items-center justify-center rounded-full' className='w-full h-full cursor-default flex items-center justify-center rounded-full'
style={{ style={{
backgroundColor: !node.selected ? node.data.fill : APP_COLORS.bgActiveSelection, backgroundColor: node.selected
? APP_COLORS.bgActiveSelection
: focusCst === node.data
? APP_COLORS.bgPurple
: colorBgGraphNode(node.data, coloring),
fontSize: label.length > LABEL_THRESHOLD ? FONT_SIZE_MED : FONT_SIZE_MAX fontSize: label.length > LABEL_THRESHOLD ? FONT_SIZE_MED : FONT_SIZE_MAX
}} }}
data-tooltip-id={globalIDs.tooltip} data-tooltip-id={globalIDs.tooltip}
data-tooltip-html={describeCstNode(node.data.cst)} data-tooltip-html={describeCstNode(node.data)}
data-tooltip-hidden={node.dragging}
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
> >
<div <div
style={{ style={{
@ -66,6 +85,8 @@ export function TGNode(node: TGNodeInternal) {
style={{ style={{
fontSize: description.length > DESCRIPTION_THRESHOLD ? FONT_SIZE_MIN : FONT_SIZE_MED fontSize: description.length > DESCRIPTION_THRESHOLD ? FONT_SIZE_MIN : FONT_SIZE_MED
}} }}
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
> >
<div className='absolute top-0 px-1 left-0 text-center w-full line-clamp-3 hover:line-clamp-none'> <div className='absolute top-0 px-1 left-0 text-center w-full line-clamp-3 hover:line-clamp-none'>
{description} {description}

View File

@ -0,0 +1,74 @@
import { CstType } from '../../../backend/types';
import { type IConstituenta, type IRSForm } from '../../../models/rsform';
import { type GraphFilterParams, useTermGraphStore } from '../../../stores/termGraph';
import { useRSEdit } from '../RSEditContext';
export function useFilteredGraph() {
const { schema, focusCst } = useRSEdit();
const filter = useTermGraphStore(state => state.filter);
const filteredGraph = produceFilteredGraph(schema, filter, focusCst);
const hidden = schema.items.filter(cst => !filteredGraph.hasNode(cst.id)).map(cst => cst.id);
return { filteredGraph, hidden };
}
// ====== Internals =========
function produceFilteredGraph(schema: IRSForm, params: GraphFilterParams, focusCst: IConstituenta | null) {
const filtered = schema.graph.clone();
const allowedTypes: CstType[] = (() => {
const result: CstType[] = [];
if (params.allowBase) result.push(CstType.BASE);
if (params.allowStruct) result.push(CstType.STRUCTURED);
if (params.allowTerm) result.push(CstType.TERM);
if (params.allowAxiom) result.push(CstType.AXIOM);
if (params.allowFunction) result.push(CstType.FUNCTION);
if (params.allowPredicate) result.push(CstType.PREDICATE);
if (params.allowConstant) result.push(CstType.CONSTANT);
if (params.allowTheorem) result.push(CstType.THEOREM);
return result;
})();
if (params.noHermits) {
filtered.removeIsolated();
}
if (params.noTemplates) {
schema.items.forEach(cst => {
if (cst !== focusCst && cst.is_template) {
filtered.foldNode(cst.id);
}
});
}
if (allowedTypes.length < Object.values(CstType).length) {
schema.items.forEach(cst => {
if (cst !== focusCst && !allowedTypes.includes(cst.cst_type)) {
filtered.foldNode(cst.id);
}
});
}
if (!focusCst && params.foldDerived) {
schema.items.forEach(cst => {
if (cst.spawner) {
filtered.foldNode(cst.id);
}
});
}
if (focusCst) {
const includes: number[] = [
focusCst.id,
...focusCst.spawn,
...(params.focusShowInputs ? schema.graph.expandInputs([focusCst.id]) : []),
...(params.focusShowOutputs ? schema.graph.expandOutputs([focusCst.id]) : [])
];
schema.items.forEach(cst => {
if (!includes.includes(cst.id)) {
filtered.foldNode(cst.id);
}
});
}
if (params.noTransitive) {
filtered.transitiveReduction();
}
return filtered;
}

View File

@ -31,6 +31,7 @@ export enum RSTabID {
export interface IRSEditContext { export interface IRSEditContext {
schema: IRSForm; schema: IRSForm;
selected: number[]; selected: number[];
focusCst: IConstituenta | null;
activeCst: IConstituenta | null; activeCst: IConstituenta | null;
activeVersion?: number; activeVersion?: number;
@ -48,6 +49,7 @@ export interface IRSEditContext {
deleteSchema: () => void; deleteSchema: () => void;
setFocus: (newValue: IConstituenta | null) => void;
setSelected: React.Dispatch<React.SetStateAction<number[]>>; setSelected: React.Dispatch<React.SetStateAction<number[]>>;
select: (target: number) => void; select: (target: number) => void;
deselect: (target: number) => void; deselect: (target: number) => void;
@ -103,6 +105,7 @@ export const RSEditState = ({
const [selected, setSelected] = useState<number[]>([]); const [selected, setSelected] = useState<number[]>([]);
const canDeleteSelected = selected.length > 0 && selected.every(id => !schema.cstByID.get(id)?.is_inherited); const canDeleteSelected = selected.length > 0 && selected.every(id => !schema.cstByID.get(id)?.is_inherited);
const [focusCst, setFocusCst] = useState<IConstituenta | null>(null);
const activeCst = selected.length === 0 ? null : schema.cstByID.get(selected[selected.length - 1])!; const activeCst = selected.length === 0 ? null : schema.cstByID.get(selected[selected.length - 1])!;
@ -125,6 +128,11 @@ export const RSEditState = ({
[schema, adjustRole, isOwned, user, adminMode] [schema, adjustRole, isOwned, user, adminMode]
); );
function handleSetFocus(newValue: IConstituenta | null) {
setFocusCst(newValue);
setSelected([]);
}
function navigateVersion(versionID?: number) { function navigateVersion(versionID?: number) {
router.push(urls.schema(schema.id, versionID)); router.push(urls.schema(schema.id, versionID));
} }
@ -311,6 +319,7 @@ export const RSEditState = ({
<RSEditContext <RSEditContext
value={{ value={{
schema, schema,
focusCst,
selected, selected,
activeCst, activeCst,
activeVersion, activeVersion,
@ -329,6 +338,7 @@ export const RSEditState = ({
deleteSchema, deleteSchema,
setFocus: handleSetFocus,
setSelected, setSelected,
select: (target: number) => setSelected(prev => [...prev, target]), select: (target: number) => setSelected(prev => [...prev, target]),
deselect: (target: number) => setSelected(prev => prev.filter(id => id !== target)), deselect: (target: number) => setSelected(prev => prev.filter(id => id !== target)),

View File

@ -29,7 +29,7 @@ export function RSTabs({ activeID, activeTab }: RSTabsProps) {
const hideFooter = useAppLayoutStore(state => state.hideFooter); const hideFooter = useAppLayoutStore(state => state.hideFooter);
const { setIsModified } = useModificationStore(); const { setIsModified } = useModificationStore();
const { schema, selected, setSelected, navigateRSForm } = useRSEdit(); const { schema, selected, setSelected, deselectAll, navigateRSForm } = useRSEdit();
useLayoutEffect(() => { useLayoutEffect(() => {
const oldTitle = document.title; const oldTitle = document.title;
@ -46,11 +46,11 @@ export function RSTabs({ activeID, activeTab }: RSTabsProps) {
if (activeID && schema.cstByID.has(activeID)) { if (activeID && schema.cstByID.has(activeID)) {
setSelected([activeID]); setSelected([activeID]);
} else { } else {
setSelected([]); deselectAll();
} }
} }
return () => hideFooter(false); return () => hideFooter(false);
}, [activeTab, activeID, setSelected, schema, hideFooter, setIsModified]); }, [activeTab, activeID, setSelected, deselectAll, schema, hideFooter, setIsModified]);
function onSelectTab(index: number, last: number, event: Event) { function onSelectTab(index: number, last: number, event: Event) {
if (last === index) { if (last === index) {