Skip to content

Commit

Permalink
Adds CSS rules on SVG to help performances. (#78)
Browse files Browse the repository at this point in the history
* Adds CSS rules on SVG to help performances.
Before loading, rules to hide (cull) are added, then, after a zoom threshold is passed, the elements are shown.

---------

Signed-off-by: BOUTIER Charly <charly.boutier@rte-france.com>
  • Loading branch information
EstherDarkish authored Sep 9, 2024
1 parent 3346428 commit 150536c
Show file tree
Hide file tree
Showing 5 changed files with 258 additions and 17 deletions.
28 changes: 21 additions & 7 deletions demo/src/diagram-viewers/add-diagrams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export const addNadToDemo = () => {
handleNodeMove,
handleTextNodeMove,
handleNodeSelect,
true
true,
false,
null
);

document
Expand All @@ -65,7 +67,9 @@ export const addNadToDemo = () => {
handleNodeMove,
handleTextNodeMove,
handleNodeSelect,
false
false,
false,
null
);

document
Expand All @@ -87,7 +91,9 @@ export const addNadToDemo = () => {
handleNodeMove,
handleTextNodeMove,
handleNodeSelect,
true
true,
false,
null
);

document
Expand All @@ -109,7 +115,9 @@ export const addNadToDemo = () => {
handleNodeMove,
handleTextNodeMove,
handleNodeSelect,
true
true,
false,
null
);

document
Expand All @@ -131,7 +139,9 @@ export const addNadToDemo = () => {
handleNodeMove,
handleTextNodeMove,
handleNodeSelect,
true
true,
false,
null
);

document
Expand All @@ -153,7 +163,9 @@ export const addNadToDemo = () => {
handleNodeMove,
handleTextNodeMove,
handleNodeSelect,
true
true,
false,
null
);

document
Expand All @@ -175,7 +187,9 @@ export const addNadToDemo = () => {
handleNodeMove,
handleTextNodeMove,
handleNodeSelect,
true
true,
true,
null
);

document
Expand Down
85 changes: 85 additions & 0 deletions src/components/network-area-diagram-viewer/dynamic-css-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Copyright (c) 2022-2024, RTE (http://www.rte-france.com)
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

export enum THRESHOLD_STATUS {
BELOW = 'BELOW',
ABOVE = 'ABOVE',
}
export type CSS_DECLARATION = Record<string, string>;
export type CSS_RULE = {
cssSelector: string;
belowThresholdCssDeclaration: CSS_DECLARATION;
aboveThresholdCssDeclaration: CSS_DECLARATION;
threshold: number;
thresholdStatus: THRESHOLD_STATUS;
};

export const DEFAULT_DYNAMIC_CSS_RULES: CSS_RULE[] = [
{
cssSelector: '.nad-edge-infos', // data on edges (arrows and values)
belowThresholdCssDeclaration: { display: 'block' },
aboveThresholdCssDeclaration: { display: 'none' },
threshold: 2200,
thresholdStatus: THRESHOLD_STATUS.ABOVE,
},
{
cssSelector: '.nad-label-box', // tooltips linked to nodes
belowThresholdCssDeclaration: { display: 'block' },
aboveThresholdCssDeclaration: { display: 'none' },
threshold: 3000,
thresholdStatus: THRESHOLD_STATUS.ABOVE,
},
{
cssSelector: '.nad-text-edges', // visual link between nodes and their tooltip
belowThresholdCssDeclaration: { display: 'block' },
aboveThresholdCssDeclaration: { display: 'none' },
threshold: 3000,
thresholdStatus: THRESHOLD_STATUS.ABOVE,
},
{
cssSelector: '[class^="nad-vl0to30"], [class*=" nad-vl0to30"]',
belowThresholdCssDeclaration: { display: 'block' },
aboveThresholdCssDeclaration: { display: 'none' },
threshold: 4000,
thresholdStatus: THRESHOLD_STATUS.BELOW,
},
{
cssSelector: '[class^="nad-vl30to50"], [class*=" nad-vl30to50"]',
belowThresholdCssDeclaration: { display: 'block' },
aboveThresholdCssDeclaration: { display: 'none' },
threshold: 4000,
thresholdStatus: THRESHOLD_STATUS.BELOW,
},
{
cssSelector: '[class^="nad-vl50to70"], [class*=" nad-vl50to70"]',
belowThresholdCssDeclaration: { display: 'block' },
aboveThresholdCssDeclaration: { display: 'none' },
threshold: 9000,
thresholdStatus: THRESHOLD_STATUS.BELOW,
},
{
cssSelector: '[class^="nad-vl70to120"], [class*=" nad-vl70to120"]',
belowThresholdCssDeclaration: { display: 'block' },
aboveThresholdCssDeclaration: { display: 'none' },
threshold: 9000,
thresholdStatus: THRESHOLD_STATUS.BELOW,
},
{
cssSelector: '[class^="nad-vl120to180"], [class*=" nad-vl120to180"]',
belowThresholdCssDeclaration: { display: 'block' },
aboveThresholdCssDeclaration: { display: 'none' },
threshold: 12000,
thresholdStatus: THRESHOLD_STATUS.BELOW,
},
{
cssSelector: '[class^="nad-vl180to300"], [class*=" nad-vl180to300"]',
belowThresholdCssDeclaration: { display: 'block' },
aboveThresholdCssDeclaration: { display: 'none' },
threshold: 20000,
thresholdStatus: THRESHOLD_STATUS.BELOW,
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ describe('Test network-area-diagram-viewer', () => {
null,
null,
null,
false
false,
false,
null
);

expect(container.getElementsByTagName('svg').length).toBe(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Point, SVG, ViewBoxLike, Svg } from '@svgdotjs/svg.js';
import '@svgdotjs/svg.panzoom.js';
import * as DiagramUtils from './diagram-utils';
import { SvgParameters } from './svg-parameters';
import { CSS_DECLARATION, CSS_RULE, THRESHOLD_STATUS, DEFAULT_DYNAMIC_CSS_RULES } from './dynamic-css-utils';

type DIMENSIONS = { width: number; height: number; viewbox: VIEWBOX };
type VIEWBOX = { x: number; y: number; width: number; height: number };
Expand Down Expand Up @@ -61,6 +62,7 @@ export class NetworkAreaDiagramViewer {
onMoveTextNodeCallback: OnMoveTextNodeCallbackType | null;
onSelectNodeCallback: OnSelectNodeCallbackType | null;
shiftKeyOnMouseDown: boolean = false;
dynamicCssRules: CSS_RULE[];

constructor(
container: HTMLElement,
Expand All @@ -72,15 +74,18 @@ export class NetworkAreaDiagramViewer {
onMoveNodeCallback: OnMoveNodeCallbackType | null,
onMoveTextNodeCallback: OnMoveTextNodeCallbackType | null,
onSelectNodeCallback: OnSelectNodeCallbackType | null,
enableNodeMoving: boolean
enableNodeMoving: boolean,
enableLevelOfDetail: boolean,
customDynamicCssRules: CSS_RULE[] | null
) {
this.container = container;
this.svgContent = svgContent;
this.width = 0;
this.height = 0;
this.originalWidth = 0;
this.originalHeight = 0;
this.init(minWidth, minHeight, maxWidth, maxHeight, enableNodeMoving);
this.dynamicCssRules = customDynamicCssRules ?? DEFAULT_DYNAMIC_CSS_RULES;
this.init(minWidth, minHeight, maxWidth, maxHeight, enableNodeMoving, enableLevelOfDetail);
this.svgParameters = this.getSvgParameters();
this.onMoveNodeCallback = onMoveNodeCallback;
this.onMoveTextNodeCallback = onMoveTextNodeCallback;
Expand Down Expand Up @@ -140,17 +145,20 @@ export class NetworkAreaDiagramViewer {
}

public setViewBox(viewBox: ViewBoxLike): void {
if (viewBox !== undefined && viewBox !== null) {
this.svgDraw?.viewbox(viewBox);
}
this.svgDraw?.viewbox(viewBox);
}

public getDynamicCssRules() {
return this.dynamicCssRules;
}

public init(
minWidth: number,
minHeight: number,
maxWidth: number,
maxHeight: number,
enableNodeMoving: boolean
enableNodeMoving: boolean,
enableLevelOfDetail: boolean
): void {
if (!this.container || !this.svgContent) {
return;
Expand Down Expand Up @@ -216,6 +224,27 @@ export class NetworkAreaDiagramViewer {
firstChild.removeAttribute('width');
firstChild.removeAttribute('height');

if (enableLevelOfDetail) {
// We insert custom CSS to hide details before first load, in order to improve performances
this.initializeDynamicCssRules(Math.max(dimensions.viewbox.width, dimensions.viewbox.height));
this.injectDynamicCssRules(firstChild);
this.svgDraw.fire('zoom'); // Forces a new dynamic zoom check to correctly update the dynamic CSS

// We add an observer to track when the SVG's viewBox is updated by panzoom
// (we have to do this instead of using panzoom's 'zoom' event to have accurate viewBox updates)
const targetNode: SVGSVGElement = this.svgDraw.node;
// Callback function to execute when mutations are observed
const observerCallback = (mutationList: MutationRecord[]) => {
for (const mutation of mutationList) {
if (mutation.attributeName === 'viewBox') {
this.checkAndUpdateLevelOfDetail(targetNode);
}
}
};
const observer = new MutationObserver(observerCallback);
observer.observe(targetNode, { attributeFilter: ['viewBox'] });
}

if (enableNodeMoving) {
// fill empty elements: unknown buses and three windings transformers
const emptyElements: NodeListOf<SVGGraphicsElement> = this.container.querySelectorAll(
Expand Down Expand Up @@ -246,9 +275,9 @@ export class NetworkAreaDiagramViewer {
private enablePanzoom() {
this.svgDraw?.panZoom({
panning: true,
zoomMin: 0.5 / this.ratio,
zoomMax: 30 * this.ratio,
zoomFactor: 0.15,
zoomMin: 0.5 / this.ratio, // maximum zoom OUT ratio (0.5 = at best, the displayed area is twice the SVG's size)
zoomMax: 20 * this.ratio, // maximum zoom IN ratio (20 = at best, the displayed area is only 1/20th of the SVG's size)
zoomFactor: 0.2,
margins: { top: 0, left: 0, right: 0, bottom: 0 },
});
}
Expand Down Expand Up @@ -1198,4 +1227,113 @@ export class NetworkAreaDiagramViewer {
}
}
}

// Will explore the SVG's <style> tags to find the css rule associated with "cssSelector" and update the
// rule using "cssDeclaration".
// Will create a style tag or/and new css rule if not found in the SVG.
public updateSvgCssDisplayValue(svg: SVGSVGElement, cssSelector: string, cssDeclaration: CSS_DECLARATION) {
const innerSvg = svg.querySelector('svg');
if (!innerSvg) {
console.error('Cannot find the SVG to update!');
return;
}

let ruleFound = false;

let svgStyles = innerSvg.querySelectorAll('style');

if (svgStyles) {
for (const svgStyle of svgStyles) {
if (!svgStyle?.sheet?.cssRules) {
continue;
}
for (const rule of svgStyle.sheet.cssRules) {
const styleRule = rule as CSSStyleRule;
if (styleRule.selectorText === cssSelector) {
const key = Object.keys(cssDeclaration)[0];
const value = cssDeclaration[key];
styleRule.style.setProperty(key, value);
ruleFound = true;
break;
}
}
if (ruleFound) {
break;
}
}
} else {
innerSvg.appendChild(document.createElement('style'));
console.debug('[updateSvgCssDisplayValue] Style tag missing from SVG file. It has been created.');
svgStyles = innerSvg.querySelectorAll('style');
if (!svgStyles) {
console.error('Failed to create a style tag in the SVG!');
return;
}
}

if (!ruleFound) {
const key = Object.keys(cssDeclaration)[0];
const value = cssDeclaration[key];
const styleTag = svgStyles[svgStyles.length - 1]; // Adds the new rule to the last <style> tag in the SVG
styleTag.textContent = `${cssSelector} {${key}: ${value};}\n` + styleTag.textContent;
}
}

public initializeDynamicCssRules(maxDisplayedSize: number) {
this.getDynamicCssRules().forEach((rule) => {
rule.thresholdStatus = maxDisplayedSize < rule.threshold ? THRESHOLD_STATUS.BELOW : THRESHOLD_STATUS.ABOVE;
});
}

public injectDynamicCssRules(htmlElementSvg: HTMLElement) {
const rules = this.getDynamicCssRules()
.map((rule) => {
const ruleToInject =
rule.thresholdStatus === THRESHOLD_STATUS.BELOW
? rule.belowThresholdCssDeclaration
: rule.aboveThresholdCssDeclaration;
const key = Object.keys(ruleToInject)[0];
const value = ruleToInject[key];
return `${rule.cssSelector} {${key}: ${value};}`;
})
.join('\n');

let styleTag = htmlElementSvg.querySelector('style');
if (!styleTag) {
htmlElementSvg.appendChild(document.createElement('style'));
console.debug('[injectDynamicCssRules] Style tag missing from SVG file. It has been created.');
styleTag = htmlElementSvg.querySelector('style');
}
if (styleTag && 'textContent' in styleTag) {
styleTag.textContent = rules + styleTag.textContent;
} else {
console.error('Failed to create Style tag in SVG file!');
}
}

public getCurrentlyMaxDisplayedSize(): number {
const viewbox = this.getViewBox();
return Math.max(viewbox?.height || 0, viewbox?.width || 0);
}

public checkAndUpdateLevelOfDetail(svg: SVGSVGElement) {
const maxDisplayedSize = this.getCurrentlyMaxDisplayedSize();
// We will check each dynamic css rule to see if we crossed a zoom threshold. If this is the case, we
// update the rule's threshold status and trigger the CSS change in the SVG.
this.getDynamicCssRules().forEach((rule) => {
if (rule.thresholdStatus === THRESHOLD_STATUS.ABOVE && maxDisplayedSize < rule.threshold) {
console.debug(
'CSS Rule ' + rule.cssSelector + ' below threshold ' + maxDisplayedSize + ' < ' + rule.threshold
);
rule.thresholdStatus = THRESHOLD_STATUS.BELOW;
this.updateSvgCssDisplayValue(svg, rule.cssSelector, rule.belowThresholdCssDeclaration);
} else if (rule.thresholdStatus === THRESHOLD_STATUS.BELOW && maxDisplayedSize >= rule.threshold) {
console.debug(
'CSS Rule ' + rule.cssSelector + ' above threshold ' + maxDisplayedSize + ' >= ' + rule.threshold
);
rule.thresholdStatus = THRESHOLD_STATUS.ABOVE;
this.updateSvgCssDisplayValue(svg, rule.cssSelector, rule.aboveThresholdCssDeclaration);
}
});
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type {
OnMoveTextNodeCallbackType,
OnSelectNodeCallbackType,
} from './components/network-area-diagram-viewer/network-area-diagram-viewer';
export { THRESHOLD_STATUS } from './components/network-area-diagram-viewer/dynamic-css-utils';
export type { CSS_DECLARATION, CSS_RULE } from './components/network-area-diagram-viewer/dynamic-css-utils';
export { SingleLineDiagramViewer } from './components/single-line-diagram-viewer/single-line-diagram-viewer';
export type {
HandleTogglePopoverType,
Expand Down

0 comments on commit 150536c

Please sign in to comment.