UNPKG

@kieler/klighd-core

Version:

Core KLighD diagram visualization with Sprotty

683 lines (603 loc) 24 kB
/* * KIELER - Kiel Integrated Environment for Layout Eclipse RichClient * * http://rtsys.informatik.uni-kiel.de/kieler * * Copyright 2025 by * + Kiel University * + Department of Computer Science * + Real-Time and Embedded Systems Group * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License 2.0 which is available at * http://www.eclipse.org/legal/epl-2.0. * * SPDX-License-Identifier: EPL-2.0 */ import { inject, injectable } from 'inversify' import { ActionHandlerRegistry, IActionDispatcher, IActionHandler, SetUIExtensionVisibilityAction, SGraphImpl, TYPES, } from 'sprotty' import { Action, Bounds, CenterAction, SetModelAction, UpdateModelAction } from 'sprotty-protocol' import { KGraphData, SKGraphElement } from '@kieler/klighd-interactive/lib/constraint-classes' import { createSemanticFilter } from '../filtering/util' import { SearchBar } from './searchbar' import { SearchBarPanel } from './searchbar-panel' import { isContainerRendering, isKText, isRendering, isSKGraphElement, isSKLabel, KRectangle, KRendering, KText, SKEdge, SKLabel, SKNode, SKPort, } from '../skgraph-models' import { getReservedStructuralTags } from '../filtering/reserved-structural-tags' import { SearchResult } from './search-results' import { SendModelContextAction } from '../actions/actions' export type ShowSearchBarAction = SetUIExtensionVisibilityAction /** add UI container */ // eslint-disable-next-line no-redeclare export namespace ShowSearchBarAction { export function create(): ShowSearchBarAction { return SetUIExtensionVisibilityAction.create({ extensionId: SearchBar.ID, visible: true, }) } } /** hide/unhide the search bar panel */ export interface ToggleSearchBarAction extends Action { kind: typeof ToggleSearchBarAction.KIND state?: 'show' | 'hide' panel: SearchBarPanel } // eslint-disable-next-line no-redeclare export namespace ToggleSearchBarAction { export const KIND = 'toggleSearchBar' export function create(panel: SearchBarPanel, state?: 'show' | 'hide'): ToggleSearchBarAction { return { kind: KIND, state, panel, } } export function isThisAction(action: Action): action is ToggleSearchBarAction { return action.kind === KIND } } export interface UpdateHighlightsAction extends Action { kind: typeof UpdateHighlightsAction.KIND selectedIndex: number previousIndex: number | undefined results: SearchResult[] } // eslint-disable-next-line no-redeclare export namespace UpdateHighlightsAction { export const KIND = 'updateHighlights' export function create( currentIndex: number, prevIndex: number | undefined, results: SearchResult[] ): UpdateHighlightsAction { return { kind: KIND, selectedIndex: currentIndex, previousIndex: prevIndex, results, } } export function isThisAction(action: Action): action is UpdateHighlightsAction { return action.kind === KIND } } export interface ClearHighlightsAction extends Action { kind: typeof ClearHighlightsAction.KIND results: SearchResult[] } // eslint-disable-next-line no-redeclare export namespace ClearHighlightsAction { export const KIND = 'clearHighlights' export function create(searchResults: SearchResult[]): ClearHighlightsAction { return { kind: KIND, results: searchResults, } } export function isThisAction(action: Action): action is ClearHighlightsAction { return action.kind === KIND } } // TODO: extract this KRectangle creation to a dedicated JS KGraph creation library function createHighlightRectangle(elem: SKGraphElement, bounds: Bounds, highlight: number): KRectangle { return { type: 'KRectangleImpl', id: `highlightRect-${elem.id}`, children: [], properties: { 'klighd.rendering.highlight': highlight, 'klighd.lsp.calculated.bounds': bounds, }, actions: [], styles: [], } } export interface RetrieveTagsActions extends Action { kind: typeof RetrieveTagsAction.KIND } export namespace RetrieveTagsAction { export const KIND = 'retrieveTags' export function create(): RetrieveTagsActions { return { kind: KIND, } } export function isThisAction(action: Action): action is RetrieveTagsActions { return action.kind === KIND } } export interface SearchAction extends Action { kind: typeof SearchAction.KIND id: string textInput: string tagInput: string } // eslint-disable-next-line no-redeclare export namespace SearchAction { export const KIND = 'handleSearch' export function create(id: string, textInput: string, tagInput: string): SearchAction { return { kind: KIND, id, textInput, tagInput, } } export function isThisAction(action: Action): action is SearchAction { return action.kind === KIND } } @injectable() export class SearchBarActionHandler implements IActionHandler { private static currentModel?: SKGraphElement private OPACITY_INCREMENT: number = 2 private HIGHLIGHT_MATCH: number = 2 private HIGHLIGHT_MAIN_MATCH: number = 1 private panel: SearchBarPanel private modelChanged: boolean = false // TODO: ktexts can't have a border, so instead of setting highlight directly on the ktext, a rectangle with the correct // size should be added behind it instead (this does pose an additional issue with the foreground then not being // applied to the text itself, so it's a choice, support border or support foreground highlight) @inject(TYPES.IActionDispatcher) private actionDispatcher: IActionDispatcher initialize(registry: ActionHandlerRegistry): void { registry.register(SetModelAction.KIND, this) registry.register(UpdateModelAction.KIND, this) registry.register(SendModelContextAction.KIND, this) registry.register(ToggleSearchBarAction.KIND, this) registry.register(SearchAction.KIND, this) registry.register(ClearHighlightsAction.KIND, this) registry.register(UpdateHighlightsAction.KIND, this) registry.register(RetrieveTagsAction.KIND, this) } handle(action: Action): void { /* Intercept model from rendering step */ if (action.kind === SendModelContextAction.KIND) { const root: SGraphImpl = (action as SendModelContextAction).model if (root.type !== 'graph') { return } SearchBarActionHandler.currentModel = root as unknown as SKGraphElement if (this.panel?.isVisible && this.modelChanged) { // only retrigger the search if the model changed since the last search this.modelChanged = false this.actionDispatcher.dispatch( SearchAction.create(SearchBar.ID, this.panel.textInput ?? '', this.panel.tagSearch ?? '') ) } return } if (action.kind === SetModelAction.KIND || action.kind === UpdateModelAction.KIND) { this.modelChanged = true return } if (!SearchBarActionHandler.currentModel) return const modelId = SearchBarActionHandler.currentModel?.id if (ToggleSearchBarAction.isThisAction(action)) { if (!this.panel) { this.panel = action.panel } const newVisible = action.state === 'show' if (this.panel.isVisible !== newVisible) { this.panel.changeVisibility(newVisible) this.panel.update() } } else if (ClearHighlightsAction.isThisAction(action)) { /* Handle ClearHighlightsActions */ this.removeHighlights(action.results) // make changes visible if (modelId && this.actionDispatcher) { this.actionDispatcher.dispatch(CenterAction.create([modelId])) } } else if (UpdateHighlightsAction.isThisAction(action)) { /* Update highlights to show current result orange */ if (action.selectedIndex === undefined || !action.results || !this.panel) return this.updateHighlights(action.selectedIndex, action.previousIndex, action.results) if (modelId && this.actionDispatcher) { this.actionDispatcher.dispatch(CenterAction.create([modelId])) } } else if (RetrieveTagsAction.isThisAction(action)) { /* searches for all tags on the model */ if (!this.panel) return this.retrieveTags(SearchBarActionHandler.currentModel) } else if (SearchAction.isThisAction(action)) { /* Handle search itself */ const query = action.textInput.trim().toLowerCase() const tagQuery = action.tagInput const results: SearchResult[] = this.searchModel(SearchBarActionHandler.currentModel, query, tagQuery) this.highlightSearchResults(results) this.updateHighlights(this.panel.getLastActiveIndex, undefined, results) if (modelId && this.actionDispatcher) { this.actionDispatcher.dispatch(CenterAction.create([modelId])) } this.panel.setResults(results) this.panel.update() } } /** * Looks for all tags on the current graph to display them on the panel. * @param root the model * @param panel the search bar panel */ private retrieveTags(root: SKGraphElement): void { const results = this.searchModel(root, '', 'true').map((result) => result.element) if (!results) return const seenTags = new Set<string>() const tags: { tag: string; num?: number }[] = getReservedStructuralTags().map((tag) => ({ tag })) const collectFrom = (obj: SKGraphElement | KRendering) => { const tagProp = obj?.properties?.['de.cau.cs.kieler.klighd.semanticFilter.tags'] if (Array.isArray(tagProp)) { for (const item of tagProp) { if (typeof item.tag === 'string') { const { tag } = item if (!seenTags.has(tag)) { seenTags.add(tag) tags.push({ tag, num: item.num }) } } } } } while (results.length > 0) { const currentElem = results.shift()! collectFrom(currentElem) if (Array.isArray(currentElem.data)) { for (const child of currentElem.data) { if (isRendering(child)) { collectFrom(child) } } } } tags.sort((a, b) => a.tag.localeCompare(b.tag)) this.panel.setTags(tags) } /** * Remove all highlights * @param results the highlighted results */ private removeHighlights(searchResults: SearchResult[]): void { for (const result of searchResults) { this.removeSpecificHighlight(result) } } /** * Remove a specific highlight * @param searchResult the search result for which to remove the highlight */ private removeSpecificHighlight(searchResult: SearchResult) { const elemID = searchResult.element.id const { element, kText } = searchResult if (kText) { kText.properties['klighd.rendering.highlight'] = 0 } if (isContainerRendering(element)) { element.removeAll((child) => !child.id?.includes(`highlightRect-${elemID}`)) } const { data } = element for (const item of data) { if (isContainerRendering(item)) { item.children = item.children.filter( (child: { id: string }) => !child.id?.includes(`highlightRect-${elemID}`) ) } else if (isRendering(item)) { item.properties['klighd.rendering.highlight'] = 0 } } } /** * Adds highlighting to labels or nodes * @param searchResult the the search result that shall be highlighted * @param highlight the type of highlight (HIGHLIGHT_MATCH, HIGHLIGHT_MAIN_MATCH, +OPACITY_INCREMENT) */ private addHighlightToElement(searchResult: SearchResult, highlight: number): void { const bounds = this.extractBounds(searchResult.element) const { data } = searchResult.element if (data !== undefined) { for (const item of data) { if (isContainerRendering(item)) { const alreadyHasHighlight = item.children?.some((child) => child.id?.startsWith('highlightRect-')) if (!alreadyHasHighlight) { const highlightRect = createHighlightRectangle( searchResult.element, bounds, highlight + this.OPACITY_INCREMENT ) item.children = [...(item.children ?? []), highlightRect] } } else if (isKText(item)) { item.properties['klighd.rendering.highlight'] = highlight } } } } /** * Highlights the selectedIndex-th result orange and keeps the other indices yellow * @param selectedIndex the results the user is currenlty panned to. * @param lastIndex the previous selectedIndex (currently orange -> needs to be yellow) * @param results the search results */ private updateHighlights(selectedIndex: number, lastIndex: number | undefined, results: SearchResult[]): void { if (selectedIndex >= results.length) return if (selectedIndex === lastIndex) return this.removeSpecificHighlight(results[selectedIndex]) const lastElem = lastIndex !== undefined ? results[lastIndex] : undefined if (lastElem) this.removeSpecificHighlight(lastElem) if (this.panel.textInput === '') { if (lastElem) this.addHighlightToElement(lastElem, this.HIGHLIGHT_MATCH) this.addHighlightToElement(results[selectedIndex], this.HIGHLIGHT_MAIN_MATCH) } else { if (results[selectedIndex].kText) { results[selectedIndex].kText!.properties['klighd.rendering.highlight'] = this.HIGHLIGHT_MAIN_MATCH } else { this.addHighlightToElement(results[selectedIndex], this.HIGHLIGHT_MAIN_MATCH) } if (lastElem) { if (lastElem.kText) { lastElem.kText.properties['klighd.rendering.highlight'] = this.HIGHLIGHT_MATCH } else { this.addHighlightToElement(lastElem, this.HIGHLIGHT_MATCH) } } } } /** * Highlights all search results * @param results the search results to highlight */ private highlightSearchResults(results: SearchResult[]) { for (const result of results) { if (result.kText) { result.kText.properties['klighd.rendering.highlight'] = this.HIGHLIGHT_MATCH } else { this.addHighlightToElement(result, this.HIGHLIGHT_MATCH) } } } /** * Checks if text matches query and possibly adds the element to results with highlighting * @param parent the graph element containing the text * @param element the rendering containing the text or the label itself * @param query the user input * @param bounds the position and size of the possible highlight * @param results the array containing all results * @param textRes the array containing all {@param text} matches */ private processTextMatch( parent: SKGraphElement, element: KText | SKLabel, query: string, filter: (el: SKGraphElement) => boolean, results: SearchResult[], regex: RegExp | undefined ): void { const { text } = element as unknown as KText const matches = regex ? regex.test(text) : text.toLowerCase().includes(query) if (matches && filter(parent)) { const result = new SearchResult(parent, undefined, text) if (isKText(element)) { result.kText = element } results.push(result) } } /** * Add an element to the results and highlight it. * @param element the graph element * @param results the array containing all results * @param textRes the array containing all text matches */ private processElement(element: SKGraphElement, results: SearchResult[]) { const name = this.extractDisplayName(element) const result = new SearchResult(element, undefined, name) results.push(result) } /** * Extracts bounds from an element * @param element the element, whose bounds need to be extracted */ private extractBounds(element: SKGraphElement): Bounds { if (element instanceof SKNode || element instanceof SKPort || element instanceof SKLabel) { return { x: 0, y: 0, width: element.bounds.width, height: element.bounds.height } } if (element instanceof SKEdge) { let minX = Number.MAX_VALUE let minY = Number.MAX_VALUE let maxX = Number.MIN_VALUE let maxY = Number.MIN_VALUE for (const point of element.routingPoints) { if (point.x < minX) { minX = point.x } if (point.y < minY) { minY = point.y } if (point.x > maxX) { maxX = point.x } if (point.y > maxY) { maxY = point.y } } const PADDING = 5 return { x: minX - PADDING, y: minY - PADDING, width: maxX - minX + 2 * PADDING, height: maxY - minY + 2 * PADDING, } } return { x: -1, y: -1, width: -1, height: -1 } } /** * Helper function for regular expressions * @param query the user input * @returns a parsed regular expression or an error */ private compileRegex(query: string): RegExp | undefined { try { return new RegExp(query, 'i') } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e) this.panel.setError(errorMessage) return undefined } } /** * Perform a breadth-first search on {@param root} to find {@param query} * @param root the model * @param query the user input * @returns array of results */ private searchModel(root: SKGraphElement, query: string, tagQuery: string): SearchResult[] { const results: SearchResult[] = [] const regex = this.panel.isRegex ? this.compileRegex(query) : undefined const lowerQuery = query.toLowerCase() const queue: (SKGraphElement | KRendering)[] = [root as SKGraphElement] if (query === '' && tagQuery === '') { return results } let filter: (el: SKGraphElement) => boolean = () => true if (tagQuery !== '') { filter = createSemanticFilter(tagQuery) } while (queue.length > 0) { const element = queue.shift()! if (query === '') { /* add all elements if text query is empty */ if (isSKGraphElement(element)) { try { if (filter(element)) { this.processElement(element, results) } } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e) this.panel.setError(errorMessage) return results } } } else { /* handle elements with text field */ if (isSKLabel(element)) { const { text } = element if (text.trim()) { this.processTextMatch(element, element, lowerQuery, filter, results, regex) } } /* Process data field for renderings */ if (isSKGraphElement(element)) { const dataArr: KGraphData[] = element.data if (dataArr.length > 0) { const data = dataArr[0] if (isContainerRendering(data)) { for (const child of data.children) { this.visitRendering(child, element, lowerQuery, filter, results, regex) } } else if (isRendering(data)) { this.visitRendering(data, element, lowerQuery, filter, results, regex) } } } } /* Add children to queue */ if (isContainerRendering(element) || isSKGraphElement(element) || element.type === 'graph') { // TODO: bad don't do this for (const child of (element as any).children) { queue.push(child as SKGraphElement | KRendering) } } } return results } /** * Go into a rendering to look for the text field and compare it to the input * @param rendering KText or KLabel * @param parent KContainerRendering that contains {@param rendering} * @param query the query string * @param filter the filter function * @param results the results object to store search results in * @param regex a regex if there is one */ private visitRendering( rendering: KRendering, parent: SKGraphElement, query: string, filter: (el: SKGraphElement) => boolean, results: SearchResult[], regex: RegExp | undefined ): void { if (!rendering) return /* Check KText */ if (isKText(rendering) && rendering.text) { this.processTextMatch(parent, rendering, query, filter, results, regex) } /* Check KContainerElements */ if (isContainerRendering(rendering)) { for (const child of rendering.children ?? []) { this.visitRendering(child, parent, query, filter, results, regex) } } } /** * Finds a name to display for nodes that meet the searched tags * @param element the node * @returns name for result list */ private extractDisplayName(element: SKGraphElement): string { const segments = element.id.split('$') for (let i = segments.length - 1; i >= 0; i--) { const segment = segments[i] if (segment?.length > 1) { switch (segment.charAt(0)) { case 'N': case 'E': case 'P': case 'L': return segment.substring(1) default: break } } } return element.id } }