Add focus cst UI

This commit is contained in:
IRBorisov 2024-04-09 13:47:18 +03:00
parent 85d756309e
commit 4d72690234
12 changed files with 286 additions and 129 deletions

View File

@ -6,7 +6,7 @@ function HelpConstituenta() {
return (
<div className='dense'>
<h1>Редактор конституент</h1>
<p>При выделении также подсвечиваются производные и основание</p>
<p>Помимо активной конституенты выделяются порожденные и основание</p>
<p><b>Сохранить изменения</b>: Ctrl + S или клик по кнопке Сохранить</p>
<p className='mt-1'><b>Формальное определение</b></p>
<p>- Ctrl + Пробел дополняет до незанятого имени</p>

View File

@ -56,8 +56,8 @@ function DlgGraphParams({ hideWindow, initial, onConfirm }: DlgGraphParamsProps)
setValue={value => updateParams({ noTransitive: value })}
/>
<Checkbox
label='Свернуть производные'
title='Не отображать производные понятия'
label='Свернуть порожденные'
title='Не отображать порожденные понятия'
value={params.foldDerived}
setValue={value => updateParams({ foldDerived: value })}
/>

View File

@ -116,6 +116,7 @@ export class Graph {
const result: GraphNode[] = [];
this.nodes.forEach(node => {
if (node.outputs.length === 0 && node.inputs.length === 0) {
result.push(node);
this.nodes.delete(node.id);
}
});

View File

@ -103,6 +103,9 @@ export interface GraphFilterParams {
noText: boolean;
foldDerived: boolean;
focusShowInputs: boolean;
focusShowOutputs: boolean;
allowBase: boolean;
allowStruct: boolean;
allowTerm: boolean;

View File

@ -7,6 +7,7 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import InfoConstituenta from '@/components/info/InfoConstituenta';
import SelectedCounter from '@/components/info/SelectedCounter';
import SelectGraphToolbar from '@/components/select/SelectGraphToolbar';
import { GraphCanvasRef, GraphEdge, GraphLayout, GraphNode } from '@/components/ui/GraphUI';
import Overlay from '@/components/ui/Overlay';
import { useConceptOptions } from '@/context/OptionsContext';
@ -14,12 +15,14 @@ import DlgGraphParams from '@/dialogs/DlgGraphParams';
import useLocalStorage from '@/hooks/useLocalStorage';
import { GraphColoring, GraphFilterParams, GraphSizing } from '@/models/miscellaneous';
import { applyNodeSizing } from '@/models/miscellaneousAPI';
import { ConstituentaID, CstType } from '@/models/rsform';
import { ConstituentaID, CstType, IConstituenta } from '@/models/rsform';
import { isBasicConcept } from '@/models/rsformAPI';
import { colorBgGraphNode } from '@/styling/color';
import { PARAMETER, storage } from '@/utils/constants';
import { convertBase64ToBlob } from '@/utils/utils';
import { useRSEdit } from '../RSEditContext';
import FocusToolbar from './FocusToolbar';
import GraphSelectors from './GraphSelectors';
import GraphToolbar from './GraphToolbar';
import TermGraph from './TermGraph';
@ -41,6 +44,9 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
noText: false,
foldDerived: false,
focusShowInputs: false,
focusShowOutputs: false,
allowBase: true,
allowStruct: true,
allowTerm: true,
@ -51,7 +57,8 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
allowTheorem: true
});
const [showParamsDialog, setShowParamsDialog] = useState(false);
const filtered = useGraphFilter(controller.schema, filterParams);
const [focusCst, setFocusCst] = useState<IConstituenta | undefined>(undefined);
const filtered = useGraphFilter(controller.schema, filterParams, focusCst);
const graphRef = useRef<GraphCanvasRef | null>(null);
const [hidden, setHidden] = useState<ConstituentaID[]>([]);
@ -93,7 +100,7 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
if (cst) {
result.push({
id: String(node.id),
fill: colorBgGraphNode(cst, coloring, colors),
fill: focusCst === cst ? colors.bgPurple : colorBgGraphNode(cst, coloring, colors),
label: cst.alias,
subLabel: !filterParams.noText ? cst.term_resolved : undefined,
size: applyNodeSizing(cst, sizing)
@ -101,23 +108,25 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
}
});
return result;
}, [controller.schema, coloring, sizing, filtered.nodes, filterParams.noText, colors]);
}, [controller.schema, coloring, sizing, filtered.nodes, filterParams.noText, colors, focusCst]);
const edges: GraphEdge[] = useMemo(() => {
const result: GraphEdge[] = [];
let edgeID = 1;
filtered.nodes.forEach(source => {
source.outputs.forEach(target => {
result.push({
id: String(edgeID),
source: String(source.id),
target: String(target)
});
edgeID += 1;
if (nodes.find(node => node.id === String(target))) {
result.push({
id: String(edgeID),
source: String(source.id),
target: String(target)
});
edgeID += 1;
}
});
});
return result;
}, [filtered.nodes]);
}, [filtered.nodes, nodes]);
function handleCreateCst() {
if (!controller.schema) {
@ -174,6 +183,7 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
}
if (event.key === 'Escape') {
event.preventDefault();
setFocusCst(undefined);
controller.deselectAll();
}
}
@ -188,6 +198,17 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
}, PARAMETER.graphRefreshDelay);
}, [setFilterParams, setToggleResetView]);
const handleSetFocus = useCallback(
(cstID: ConstituentaID | undefined) => {
const target = cstID !== undefined ? controller.schema?.cstByID.get(cstID) : cstID;
setFocusCst(prev => (prev === target ? undefined : target));
if (target) {
controller.setSelected([]);
}
},
[controller]
);
const graph = useMemo(
() => (
<TermGraph
@ -202,6 +223,7 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
onDeselect={controller.deselect}
setHoverID={setHoverID}
onEdit={onOpenEdit}
onSelectCentral={handleSetFocus}
toggleResetView={toggleResetView}
/>
),
@ -217,7 +239,8 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
onOpenEdit,
toggleResetView,
controller.select,
controller.deselect
controller.deselect,
handleSetFocus
]
);
@ -240,25 +263,57 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
position='top-[4.3rem] sm:top-[0.3rem] left-0'
/>
<GraphToolbar
is3D={is3D}
orbit={orbit}
noText={filterParams.noText}
foldDerived={filterParams.foldDerived}
showParamsDialog={() => setShowParamsDialog(true)}
onCreate={handleCreateCst}
onDelete={handleDeleteCst}
onResetViewpoint={() => setToggleResetView(prev => !prev)}
onSaveImage={handleSaveImage}
toggleOrbit={() => setOrbit(prev => !prev)}
toggleFoldDerived={handleFoldDerived}
toggleNoText={() =>
setFilterParams(prev => ({
...prev,
noText: !prev.noText
}))
}
/>
<Overlay
position='top-0 pt-1 right-1/2 translate-x-1/2'
className='flex flex-col items-center rounded-b-2xl cc-blur'
>
<GraphToolbar
is3D={is3D}
orbit={orbit}
noText={filterParams.noText}
foldDerived={filterParams.foldDerived}
showParamsDialog={() => setShowParamsDialog(true)}
onCreate={handleCreateCst}
onDelete={handleDeleteCst}
onResetViewpoint={() => setToggleResetView(prev => !prev)}
onSaveImage={handleSaveImage}
toggleOrbit={() => setOrbit(prev => !prev)}
toggleFoldDerived={handleFoldDerived}
toggleNoText={() =>
setFilterParams(prev => ({
...prev,
noText: !prev.noText
}))
}
/>
{!focusCst ? (
<SelectGraphToolbar
graph={controller.schema!.graph}
core={controller.schema!.items.filter(cst => isBasicConcept(cst.cst_type)).map(cst => cst.id)}
setSelected={controller.setSelected}
/>
) : null}
{focusCst ? (
<FocusToolbar
center={focusCst}
reset={() => handleSetFocus(undefined)}
showInputs={filterParams.focusShowInputs}
showOutputs={filterParams.focusShowOutputs}
toggleShowInputs={() =>
setFilterParams(prev => ({
...prev,
focusShowInputs: !prev.focusShowInputs
}))
}
toggleShowOutputs={() =>
setFilterParams(prev => ({
...prev,
focusShowOutputs: !prev.focusShowOutputs
}))
}
/>
) : null}
</Overlay>
{hoverCst ? (
<Overlay
@ -286,6 +341,7 @@ function EditorTermGraph({ onOpenEdit }: EditorTermGraphProps) {
schema={controller.schema}
coloringScheme={coloring}
toggleSelection={controller.toggleSelect}
setFocus={handleSetFocus}
onEdit={onOpenEdit}
/>
</div>

View File

@ -0,0 +1,75 @@
'use client';
import { useCallback } from 'react';
import { IconGraphInputs, IconGraphOutputs, IconReset } from '@/components/Icons';
import MiniButton from '@/components/ui/MiniButton';
import { useConceptOptions } from '@/context/OptionsContext';
import { IConstituenta } from '@/models/rsform';
import { useRSEdit } from '../RSEditContext';
interface FocusToolbarProps {
center: IConstituenta;
showInputs: boolean;
showOutputs: boolean;
reset: () => void;
toggleShowInputs: () => void;
toggleShowOutputs: () => void;
}
function FocusToolbar({
center,
reset,
showInputs,
showOutputs,
toggleShowInputs,
toggleShowOutputs
}: FocusToolbarProps) {
const { colors } = useConceptOptions();
const controller = useRSEdit();
const resetSelection = useCallback(() => {
reset();
controller.setSelected([]);
}, [reset, controller]);
return (
<div className='cc-icons items-center'>
<div className='w-[7.8rem] text-right select-none' style={{ color: colors.fgPurple }}>
Фокус
<b className='px-1'> {center.alias} </b>
</div>
<MiniButton
titleHtml='Сбросить фокус'
icon={<IconReset size='1.25rem' className='icon-primary' />}
onClick={resetSelection}
/>
<MiniButton
title={showInputs ? 'Скрыть поставщиков' : 'Отобразить поставщиков'}
icon={
showInputs ? (
<IconGraphInputs size='1.25rem' className='icon-green' />
) : (
<IconGraphInputs size='1.25rem' className='icon-primary' />
)
}
onClick={toggleShowInputs}
/>
<MiniButton
title={showOutputs ? 'Скрыть потребителей' : 'Отобразить потребителей'}
icon={
showOutputs ? (
<IconGraphOutputs size='1.25rem' className='icon-green' />
) : (
<IconGraphOutputs size='1.25rem' className='icon-primary' />
)
}
onClick={toggleShowOutputs}
/>
</div>
);
}
export default FocusToolbar;

View File

@ -1,5 +1,3 @@
'use client';
import {
IconClustering,
IconClusteringOff,
@ -13,11 +11,8 @@ import {
IconTextOff
} from '@/components/Icons';
import BadgeHelp from '@/components/man/BadgeHelp';
import SelectGraphToolbar from '@/components/select/SelectGraphToolbar';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import { HelpTopic } from '@/models/miscellaneous';
import { isBasicConcept } from '@/models/rsformAPI';
import { useRSEdit } from '../RSEditContext';
@ -56,78 +51,68 @@ function GraphToolbar({
const controller = useRSEdit();
return (
<Overlay
position='top-0 pt-1 right-1/2 translate-x-1/2'
className='flex flex-col items-center rounded-b-2xl cc-blur'
>
<div className='cc-icons'>
<MiniButton
title='Настройки фильтрации узлов и связей'
icon={<IconFilter size='1.25rem' className='icon-primary' />}
onClick={showParamsDialog}
/>
<MiniButton
icon={<IconFitImage size='1.25rem' className='icon-primary' />}
title='Граф целиком'
onClick={onResetViewpoint}
/>
<MiniButton
title={!noText ? 'Скрыть текст' : 'Отобразить текст'}
icon={
!noText ? (
<IconText size='1.25rem' className='icon-green' />
) : (
<IconTextOff size='1.25rem' className='icon-primary' />
)
}
onClick={toggleNoText}
/>
<MiniButton
title={!foldDerived ? 'Скрыть производные' : 'Отобразить производные'}
icon={
!foldDerived ? (
<IconClustering size='1.25rem' className='icon-green' />
) : (
<IconClusteringOff size='1.25rem' className='icon-primary' />
)
}
onClick={toggleFoldDerived}
/>
<MiniButton
icon={<IconRotate3D size='1.25rem' className={orbit ? 'icon-green' : 'icon-primary'} />}
title='Анимация вращения'
disabled={!is3D}
onClick={toggleOrbit}
/>
{controller.isContentEditable ? (
<MiniButton
title='Новая конституента'
icon={<IconNewItem size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing}
onClick={onCreate}
/>
) : null}
{controller.isContentEditable ? (
<MiniButton
title='Удалить выбранные'
icon={<IconDestroy size='1.25rem' className='icon-red' />}
disabled={controller.nothingSelected || controller.isProcessing}
onClick={onDelete}
/>
) : null}
<MiniButton
icon={<IconImage size='1.25rem' className='icon-primary' />}
title='Сохранить изображение'
onClick={onSaveImage}
/>
<BadgeHelp topic={HelpTopic.GRAPH_TERM} className='max-w-[calc(100vw-4rem)]' offset={4} />
</div>
<SelectGraphToolbar
graph={controller.schema!.graph}
core={controller.schema!.items.filter(cst => isBasicConcept(cst.cst_type)).map(cst => cst.id)}
setSelected={controller.setSelected}
<div className='cc-icons'>
<MiniButton
title='Настройки фильтрации узлов и связей'
icon={<IconFilter size='1.25rem' className='icon-primary' />}
onClick={showParamsDialog}
/>
</Overlay>
<MiniButton
icon={<IconFitImage size='1.25rem' className='icon-primary' />}
title='Граф целиком'
onClick={onResetViewpoint}
/>
<MiniButton
title={!noText ? 'Скрыть текст' : 'Отобразить текст'}
icon={
!noText ? (
<IconText size='1.25rem' className='icon-green' />
) : (
<IconTextOff size='1.25rem' className='icon-primary' />
)
}
onClick={toggleNoText}
/>
<MiniButton
title={!foldDerived ? 'Скрыть порожденные' : 'Отобразить порожденные'}
icon={
!foldDerived ? (
<IconClustering size='1.25rem' className='icon-green' />
) : (
<IconClusteringOff size='1.25rem' className='icon-primary' />
)
}
onClick={toggleFoldDerived}
/>
<MiniButton
icon={<IconRotate3D size='1.25rem' className={orbit ? 'icon-green' : 'icon-primary'} />}
title='Анимация вращения'
disabled={!is3D}
onClick={toggleOrbit}
/>
{controller.isContentEditable ? (
<MiniButton
title='Новая конституента'
icon={<IconNewItem size='1.25rem' className='icon-green' />}
disabled={controller.isProcessing}
onClick={onCreate}
/>
) : null}
{controller.isContentEditable ? (
<MiniButton
title='Удалить выбранные'
icon={<IconDestroy size='1.25rem' className='icon-red' />}
disabled={controller.nothingSelected || controller.isProcessing}
onClick={onDelete}
/>
) : null}
<MiniButton
icon={<IconImage size='1.25rem' className='icon-primary' />}
title='Сохранить изображение'
onClick={onSaveImage}
/>
<BadgeHelp topic={HelpTopic.GRAPH_TERM} className='max-w-[calc(100vw-4rem)]' offset={4} />
</div>
);
}

View File

@ -20,6 +20,7 @@ interface TermGraphProps {
setHoverID: (newID: ConstituentaID | undefined) => void;
onEdit: (cstID: ConstituentaID) => void;
onSelectCentral: (selectedID: ConstituentaID) => void;
onSelect: (newID: ConstituentaID) => void;
onDeselect: (newID: ConstituentaID) => void;
@ -37,9 +38,11 @@ function TermGraph({
toggleResetView,
setHoverID,
onEdit,
onSelectCentral,
onSelect,
onDeselect
}: TermGraphProps) {
let ctrlKey: boolean = false;
const { calculateHeight, darkMode } = useConceptOptions();
const { selections, setSelections } = useSelection({
@ -63,13 +66,15 @@ function TermGraph({
const handleNodeClick = useCallback(
(node: GraphNode) => {
if (selections.includes(node.id)) {
if (ctrlKey) {
onSelectCentral(Number(node.id));
} else if (selections.includes(node.id)) {
onDeselect(Number(node.id));
} else {
onSelect(Number(node.id));
}
},
[onSelect, selections, onDeselect]
[onSelect, selections, onDeselect, onSelectCentral, ctrlKey]
);
const handleNodeDoubleClick = useCallback(
@ -96,7 +101,13 @@ function TermGraph({
const canvasHeight = useMemo(() => calculateHeight('1.75rem + 4px'), [calculateHeight]);
return (
<div className='outline-none'>
<div
className='outline-none'
tabIndex={-1}
// TODO: fix hacky way of tracking CTRL. Expose event from onNodeClick instead
onKeyUp={event => (ctrlKey = event.ctrlKey)}
onKeyDown={event => (ctrlKey = event.ctrlKey)}
>
<div className='relative' style={{ width: canvasWidth, height: canvasHeight }}>
<GraphUI
nodes={nodes}

View File

@ -2,10 +2,11 @@
import clsx from 'clsx';
import { motion } from 'framer-motion';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { IconDropArrow, IconDropArrowUp } from '@/components/Icons';
import ConstituentaTooltip from '@/components/info/ConstituentaTooltip';
import { CProps } from '@/components/props';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import { useConceptOptions } from '@/context/OptionsContext';
@ -24,15 +25,27 @@ interface ViewHiddenProps {
coloringScheme: GraphColoring;
toggleSelection: (cstID: ConstituentaID) => void;
setFocus: (cstID: ConstituentaID) => void;
onEdit: (cstID: ConstituentaID) => void;
}
function ViewHidden({ items, selected, toggleSelection, schema, coloringScheme, onEdit }: ViewHiddenProps) {
function ViewHidden({ items, selected, toggleSelection, setFocus, schema, coloringScheme, onEdit }: ViewHiddenProps) {
const { colors, calculateHeight } = useConceptOptions();
const windowSize = useWindowSize();
const localSelected = useMemo(() => items.filter(id => selected.includes(id)), [items, selected]);
const [isFolded, setIsFolded] = useLocalStorage(storage.rsgraphFoldHidden, false);
const handleClick = useCallback(
(cstID: ConstituentaID, event: CProps.EventMouse) => {
if (event.ctrlKey) {
setFocus(cstID);
} else {
toggleSelection(cstID);
}
},
[setFocus, toggleSelection]
);
if (!schema || items.length <= 0) {
return null;
}
@ -93,7 +106,7 @@ function ViewHidden({ items, selected, toggleSelection, schema, coloringScheme,
backgroundColor: colorBgGraphNode(cst, adjustedColoring, colors),
...(localSelected.includes(cstID) ? { outlineWidth: '2px', outlineStyle: 'solid' } : {})
}}
onClick={() => toggleSelection(cstID)}
onClick={event => handleClick(cstID, event)}
onDoubleClick={() => onEdit(cstID)}
>
{cst.alias}

View File

@ -2,9 +2,9 @@ import { useLayoutEffect, useMemo, useState } from 'react';
import { Graph } from '@/models/Graph';
import { GraphFilterParams } from '@/models/miscellaneous';
import { CstType, IRSForm } from '@/models/rsform';
import { ConstituentaID, CstType, IConstituenta, IRSForm } from '@/models/rsform';
function useGraphFilter(schema: IRSForm | undefined, params: GraphFilterParams) {
function useGraphFilter(schema: IRSForm | undefined, params: GraphFilterParams, focusCst: IConstituenta | undefined) {
const [filtered, setFiltered] = useState<Graph>(new Graph());
const allowedTypes: CstType[] = useMemo(() => {
@ -29,32 +29,45 @@ function useGraphFilter(schema: IRSForm | undefined, params: GraphFilterParams)
if (params.noHermits) {
graph.removeIsolated();
}
if (params.noTransitive) {
graph.transitiveReduction();
}
if (params.noTemplates) {
schema.items.forEach(cst => {
if (cst.is_template) {
if (cst !== focusCst && cst.is_template) {
graph.foldNode(cst.id);
}
});
}
if (allowedTypes.length < Object.values(CstType).length) {
schema.items.forEach(cst => {
if (!allowedTypes.includes(cst.cst_type)) {
if (cst !== focusCst && !allowedTypes.includes(cst.cst_type)) {
graph.foldNode(cst.id);
}
});
}
if (params.foldDerived) {
if (!focusCst && params.foldDerived) {
schema.items.forEach(cst => {
if (cst.parent_alias) {
if (cst.parent) {
graph.foldNode(cst.id);
}
});
}
if (focusCst) {
const includes: ConstituentaID[] = [
focusCst.id,
...focusCst.children,
...(params.focusShowInputs ? schema.graph.expandInputs([focusCst.id]) : []),
...(params.focusShowOutputs ? schema.graph.expandOutputs([focusCst.id]) : [])
];
schema.items.forEach(cst => {
if (!includes.includes(cst.id)) {
graph.foldNode(cst.id);
}
});
}
if (params.noTransitive) {
graph.transitiveReduction();
}
setFiltered(graph);
}, [schema, params, allowedTypes]);
}, [schema, params, allowedTypes, focusCst]);
return filtered;
}

View File

@ -145,11 +145,11 @@ function RSTabs() {
const onOpenCst = useCallback(
(cstID: ConstituentaID) => {
if (cstID !== activeCst?.id) {
if (cstID !== activeCst?.id || activeTab !== RSTabID.CST_EDIT) {
navigateTab(RSTabID.CST_EDIT, cstID);
}
},
[navigateTab, activeCst]
[navigateTab, activeCst, activeTab]
);
const onDestroySchema = useCallback(() => {

View File

@ -90,7 +90,7 @@ export const storage = {
librarySearchStrategy: 'library.search.strategy',
libraryPagination: 'library.pagination',
rsgraphFilter: 'rsgraph.filter_options',
rsgraphFilter: 'rsgraph.filter2',
rsgraphLayout: 'rsgraph.layout',
rsgraphColoring: 'rsgraph.coloring',
rsgraphSizing: 'rsgraph.sizing',