mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-10 21:32:43 +08:00
fix(web): restore entity type colors in knowledge graphs (#21443)
This commit is contained in:
8 files changed
+391
-10
No files matched your search
@@ -1,23 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { getKnowledgeGraphNodeLabel } from '@/components/structure-graph/use-knowledge-graph-node-style';
|
||||
|
||||
import ArtifactForceGraph from '..';
|
||||
|
||||
const mockGraphRender = jest.fn();
|
||||
const mockZoomToFit = jest.fn();
|
||||
|
||||
jest.mock('react-force-graph-2d', () => {
|
||||
const ReactLib = jest.requireActual<typeof import('react')>('react');
|
||||
const MockForceGraph = ReactLib.forwardRef<
|
||||
HTMLDivElement,
|
||||
{ zoomToFit: typeof mockZoomToFit },
|
||||
{
|
||||
graphData: { nodes: Array<{ description?: string }> };
|
||||
nodeLabel: (node: { description?: string }) => string;
|
||||
}
|
||||
>(({ graphData, nodeLabel }, ref) => {
|
||||
>((props, ref) => {
|
||||
const { graphData, nodeLabel } = props;
|
||||
mockGraphRender(props);
|
||||
ReactLib.useImperativeHandle(ref, () => ({ zoomToFit: mockZoomToFit }));
|
||||
const label = nodeLabel(graphData.nodes[0]);
|
||||
if (!label) return null;
|
||||
|
||||
// Match the graph library's HTML tooltip sink instead of rendering text.
|
||||
return ReactLib.createElement('div', {
|
||||
ref,
|
||||
'data-testid': 'node-tooltip',
|
||||
dangerouslySetInnerHTML: { __html: label },
|
||||
});
|
||||
@@ -38,7 +44,10 @@ jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
function renderGraph(description: string) {
|
||||
function renderGraph(
|
||||
description: string,
|
||||
props: Pick<import('../types').ArtifactForceGraphProps, 'getNodeLabel'> = {},
|
||||
) {
|
||||
return render(
|
||||
createElement(ArtifactForceGraph, {
|
||||
data: {
|
||||
@@ -54,6 +63,7 @@ function renderGraph(description: string) {
|
||||
],
|
||||
relations: [],
|
||||
},
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -76,4 +86,72 @@ describe('ArtifactForceGraph node tooltip', () => {
|
||||
|
||||
expect(screen.queryByTestId('node-tooltip')).toBeNull();
|
||||
});
|
||||
|
||||
it('escapes the type and description from a custom plain-text label once', () => {
|
||||
const type = '<img src=x onerror=alert(1)> <type>';
|
||||
const description = '<script>bad()</script> & 齿轮';
|
||||
renderGraph(description, {
|
||||
getNodeLabel: (node) => getKnowledgeGraphNodeLabel({ ...node, type }),
|
||||
});
|
||||
|
||||
const tooltip = screen.getByTestId('node-tooltip');
|
||||
expect(tooltip.textContent).toBe(`${type} · ${description}`);
|
||||
expect(tooltip.querySelector('img, script')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the knowledge graph type even without a description', () => {
|
||||
renderGraph('', { getNodeLabel: getKnowledgeGraphNodeLabel });
|
||||
expect(screen.getByTestId('node-tooltip')).toHaveTextContent('entity');
|
||||
});
|
||||
|
||||
it('keeps graph objects and fitting stable across label, pinned and hover changes', () => {
|
||||
mockZoomToFit.mockClear();
|
||||
const data: import('@/interfaces/database/dataset').IArtifactGraph = {
|
||||
entities: ['a', 'b', 'c'].map((slug) => ({
|
||||
slug,
|
||||
name: slug,
|
||||
aliases: [],
|
||||
description: slug,
|
||||
type: 'entity',
|
||||
weight: 1,
|
||||
})),
|
||||
relations: [{ from: 'a', to: 'b' }],
|
||||
};
|
||||
const getNodeColor = () => '#4e79a7';
|
||||
const { rerender } = render(
|
||||
<ArtifactForceGraph data={data} getNodeColor={getNodeColor} />,
|
||||
);
|
||||
const initial = mockGraphRender.mock.calls.at(-1)![0];
|
||||
const [a, b, c] = initial.graphData.nodes;
|
||||
act(() => initial.onEngineStop());
|
||||
expect(mockZoomToFit).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<ArtifactForceGraph
|
||||
data={data}
|
||||
getNodeColor={getNodeColor}
|
||||
getNodeLabel={getKnowledgeGraphNodeLabel}
|
||||
highlightNodeId="a"
|
||||
/>,
|
||||
);
|
||||
const pinned = mockGraphRender.mock.calls.at(-1)![0];
|
||||
expect(pinned.graphData).toBe(initial.graphData);
|
||||
expect(pinned.graphData.nodes[0]).toBe(a);
|
||||
expect(pinned.nodeColor(a)).toBe('#4e79a7');
|
||||
expect(pinned.nodeColor(b)).toBe('#4e79a7');
|
||||
expect(pinned.nodeColor(c)).toBe('#4e79a733');
|
||||
act(() => pinned.onEngineStop());
|
||||
expect(mockZoomToFit).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => pinned.onNodeHover(c));
|
||||
const hovered = mockGraphRender.mock.calls.at(-1)![0];
|
||||
expect(hovered.graphData).toBe(initial.graphData);
|
||||
expect(hovered.nodeColor(c)).toBe('#4e79a7');
|
||||
expect(hovered.nodeColor(a)).toBe('#4e79a733');
|
||||
act(() => hovered.onNodeHover(null));
|
||||
const restored = mockGraphRender.mock.calls.at(-1)![0];
|
||||
expect(restored.graphData).toBe(initial.graphData);
|
||||
expect(restored.nodeColor(a)).toBe('#4e79a7');
|
||||
expect(restored.nodeColor(c)).toBe('#4e79a733');
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,17 @@ import { useGraphHighlight } from './use-graph-highlight';
|
||||
import { defaultMapNodeToValue } from './utils';
|
||||
|
||||
const defaultGetNodeId = (node: IArtifactGraphEntity) => node.slug;
|
||||
/** Returns the entity description as the default plain-text tooltip. */
|
||||
const defaultGetNodeLabel = (node: IArtifactGraphEntity) =>
|
||||
node.description ?? '';
|
||||
|
||||
const nodeCanvasObjectMode = () => 'after' as const;
|
||||
|
||||
/**
|
||||
* Renders an interactive force graph with configurable node styles and labels.
|
||||
* Escapes tooltip text and coordinates zoom-to-fit, hover and pinned-node
|
||||
* highlighting, and mapped node-click callbacks.
|
||||
*/
|
||||
function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
|
||||
data,
|
||||
show = true,
|
||||
@@ -49,6 +57,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
|
||||
) => TNodeValue,
|
||||
getNodeId = defaultGetNodeId,
|
||||
getNodeColor = defaultGetNodeColor,
|
||||
getNodeLabel = defaultGetNodeLabel,
|
||||
getNodeRadius = defaultGetNodeRadius,
|
||||
highlightNodeId,
|
||||
totalEntities,
|
||||
@@ -113,10 +122,10 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
|
||||
[],
|
||||
);
|
||||
|
||||
// ForceGraph treats tooltip strings as HTML; descriptions are plain text.
|
||||
const getNodeLabel = useCallback(
|
||||
(node: ArtifactGraphNode) => escape(node.description ?? ''),
|
||||
[],
|
||||
/** Escapes plain-text node labels for ForceGraph's HTML tooltip renderer. */
|
||||
const getEscapedNodeLabel = useCallback(
|
||||
(node: ArtifactGraphNode) => escape(getNodeLabel(node)),
|
||||
[getNodeLabel],
|
||||
);
|
||||
|
||||
// Empty label hides the tooltip, so relations without a type show nothing
|
||||
@@ -140,7 +149,7 @@ function ArtifactForceGraph<TNodeValue = IArtifactGraphEntity>({
|
||||
nodeColor={nodeColor}
|
||||
nodeVal={nodeVal}
|
||||
cooldownTicks={100}
|
||||
nodeLabel={getNodeLabel}
|
||||
nodeLabel={getEscapedNodeLabel}
|
||||
autoPauseRedraw={false}
|
||||
onEngineStop={handleEngineStop}
|
||||
onNodeClick={handleNodeClick}
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface ArtifactForceGraphProps<TNodeValue = IArtifactGraphEntity> {
|
||||
mapNodeToValue?: (node: IArtifactGraphEntity) => TNodeValue;
|
||||
getNodeId?: (node: IArtifactGraphEntity) => string;
|
||||
getNodeColor?: (node: IArtifactGraphEntity) => string;
|
||||
/** Plain tooltip text; escaped once by the graph before rendering as HTML. */
|
||||
getNodeLabel?: (node: IArtifactGraphEntity) => string;
|
||||
getNodeRadius?: (
|
||||
node: IArtifactGraphEntity,
|
||||
minWeight: number,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { type IArtifactGraphEntity } from '@/interfaces/database/dataset';
|
||||
import { withAlpha } from '@/components/artifact-force-graph/utils';
|
||||
import {
|
||||
getKnowledgeGraphNodeLabel,
|
||||
useKnowledgeGraphNodeColor,
|
||||
} from '../use-knowledge-graph-node-style';
|
||||
|
||||
const Css = readFileSync(
|
||||
resolve(__dirname, '../../../../tailwind.css'),
|
||||
'utf8',
|
||||
);
|
||||
const Tokens = [
|
||||
...Css.matchAll(/(--graph-node-(?:\d+|other)):\s*(#[\da-f]{6});/g),
|
||||
];
|
||||
const entity = (type: unknown, description = ''): IArtifactGraphEntity => ({
|
||||
slug: 'test',
|
||||
name: 'test',
|
||||
aliases: [],
|
||||
type: type as string,
|
||||
description,
|
||||
weight: 1,
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
Tokens.forEach(([, name, color]) =>
|
||||
document.documentElement.style.setProperty(name, color),
|
||||
);
|
||||
});
|
||||
afterAll(() => {
|
||||
Tokens.forEach(([, name]) =>
|
||||
document.documentElement.style.removeProperty(name),
|
||||
);
|
||||
});
|
||||
|
||||
describe('knowledge graph type colors', () => {
|
||||
it('uses concrete CSS palette colors compatible with existing dimming', () => {
|
||||
expect(Tokens).toHaveLength(11);
|
||||
const { result } = renderHook(useKnowledgeGraphNodeColor);
|
||||
const colors = Tokens.map(([, , color]) => color);
|
||||
const color = result.current(entity('person'));
|
||||
expect(colors).toContain(color);
|
||||
expect(withAlpha(color, 0.2)).toBe(`${color}33`);
|
||||
});
|
||||
|
||||
it('stays stable for shuffled, filtered, expanded and newly mounted graphs', () => {
|
||||
const types = [
|
||||
'person',
|
||||
'organization',
|
||||
'地点',
|
||||
'time',
|
||||
'location',
|
||||
'任意类型',
|
||||
'🧭',
|
||||
];
|
||||
const first = renderHook(useKnowledgeGraphNodeColor);
|
||||
const initial = new Map(
|
||||
types.map((type) => [type, first.result.current(entity(type))]),
|
||||
);
|
||||
for (const type of [...types].reverse()) {
|
||||
expect(first.result.current(entity(type))).toBe(initial.get(type));
|
||||
}
|
||||
first.result.current(entity('new type'));
|
||||
const second = renderHook(useKnowledgeGraphNodeColor);
|
||||
for (const type of types.slice(2, 5)) {
|
||||
expect(second.result.current(entity(type))).toBe(initial.get(type));
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes only whitespace and case for colors, preserving original label text', () => {
|
||||
const { result } = renderHook(useKnowledgeGraphNodeColor);
|
||||
expect(result.current(entity(' Person '))).toBe(
|
||||
result.current(entity('person')),
|
||||
);
|
||||
const node = entity(' Person ', 'Original description');
|
||||
expect(getKnowledgeGraphNodeLabel(node)).toBe(
|
||||
' Person · Original description',
|
||||
);
|
||||
expect(node.type).toBe(' Person ');
|
||||
expect(node.description).toBe('Original description');
|
||||
});
|
||||
|
||||
it.each([undefined, null, '', ' ', 'other', ' OTHER ', {}, 42])(
|
||||
'uses the default color for missing/other type %p',
|
||||
(type) => {
|
||||
const { result } = renderHook(useKnowledgeGraphNodeColor);
|
||||
const defaultColor = Tokens.find(
|
||||
([, name]) => name === '--graph-node-other',
|
||||
)![2];
|
||||
expect(result.current(entity(type))).toBe(defaultColor);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps arbitrary named types, including unknown, in the categorical palette', () => {
|
||||
const { result } = renderHook(useKnowledgeGraphNodeColor);
|
||||
const defaultColor = result.current(entity('other'));
|
||||
for (const type of ['unknown', 'custom type', '中文类型', '__proto__']) {
|
||||
expect(result.current(entity(type))).not.toBe(defaultColor);
|
||||
expect(getKnowledgeGraphNodeLabel(entity(type))).toBe(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('documents real hash collisions without changing or merging their labels', () => {
|
||||
const { result } = renderHook(useKnowledgeGraphNodeColor);
|
||||
// FNV-1a UTF-16 modulo 10 is deliberately bounded, including ordinary types.
|
||||
for (const [first, second] of [
|
||||
['organization', '地点'],
|
||||
['time', 'location'],
|
||||
]) {
|
||||
expect(result.current(entity(first))).toBe(
|
||||
result.current(entity(second)),
|
||||
);
|
||||
expect(getKnowledgeGraphNodeLabel(entity(first))).not.toBe(
|
||||
getKnowledgeGraphNodeLabel(entity(second)),
|
||||
);
|
||||
}
|
||||
expect(result.current(entity('organization'))).toBe(
|
||||
Tokens.find(([, name]) => name === '--graph-node-3')![2],
|
||||
);
|
||||
expect(result.current(entity('time'))).toBe(
|
||||
Tokens.find(([, name]) => name === '--graph-node-7')![2],
|
||||
);
|
||||
// Supplementary characters hash both UTF-16 code units, not one code point.
|
||||
expect(result.current(entity('🧭'))).toBe(
|
||||
Tokens.find(([, name]) => name === '--graph-node-5')![2],
|
||||
);
|
||||
});
|
||||
|
||||
it('reads the palette once and keeps the color callback stable on rerender', () => {
|
||||
const getStyle = jest.spyOn(window, 'getComputedStyle');
|
||||
const { result, rerender } = renderHook(useKnowledgeGraphNodeColor);
|
||||
const callback = result.current;
|
||||
const calls = getStyle.mock.calls.length;
|
||||
for (let index = 0; index < 100; index++) callback(entity(`type-${index}`));
|
||||
rerender();
|
||||
expect(result.current).toBe(callback);
|
||||
expect(getStyle.mock.calls).toHaveLength(calls);
|
||||
expect(calls).toBe(1);
|
||||
getStyle.mockRestore();
|
||||
});
|
||||
|
||||
it('shows other for blank types and keeps descriptions without rewriting them', () => {
|
||||
expect(getKnowledgeGraphNodeLabel(entity(''))).toBe('other');
|
||||
expect(getKnowledgeGraphNodeLabel(entity(undefined, 'A & B'))).toBe(
|
||||
'other · A & B',
|
||||
);
|
||||
expect(getKnowledgeGraphNodeLabel(entity('person'))).toBe('person');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { CompilationTemplateKind } from '@/constants/compilation';
|
||||
import { RepresentationRenderer } from '../representation-renderer';
|
||||
|
||||
const mockGraphRender = jest.fn();
|
||||
|
||||
jest.mock('@/components/artifact-force-graph', () => ({
|
||||
__esModule: true,
|
||||
default: (props: unknown) => {
|
||||
mockGraphRender(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
jest.mock('@/components/ui/tree-view', () => ({ TreeView: () => null }));
|
||||
jest.mock('../mindmap-g6-graph', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
jest.mock('../timeline-x6-graph', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const Template = {
|
||||
kind: CompilationTemplateKind.KnowledgeGraph,
|
||||
template_id: 'test',
|
||||
template_name: 'test',
|
||||
entities: [
|
||||
{ id: 'Ada', name: 'Ada', type: 'Person', description: 'A mathematician' },
|
||||
],
|
||||
relations: [],
|
||||
};
|
||||
|
||||
describe('KnowledgeGraph styling scope', () => {
|
||||
it('keeps graph data and style callbacks stable when the highlighted node changes', () => {
|
||||
const { rerender } = render(<RepresentationRenderer template={Template} />);
|
||||
const initial = mockGraphRender.mock.calls.at(-1)![0];
|
||||
expect(initial.getNodeColor).toEqual(expect.any(Function));
|
||||
expect(initial.getNodeLabel(initial.data.entities[0])).toBe(
|
||||
'Person · A mathematician',
|
||||
);
|
||||
expect(initial.data.entities[0].description).toBe('A mathematician');
|
||||
|
||||
rerender(
|
||||
<RepresentationRenderer template={Template} highlightNodeId="Ada" />,
|
||||
);
|
||||
const highlighted = mockGraphRender.mock.calls.at(-1)![0];
|
||||
expect(highlighted.data).toBe(initial.data);
|
||||
expect(highlighted.getNodeColor).toBe(initial.getNodeColor);
|
||||
expect(highlighted.getNodeLabel).toBe(initial.getNodeLabel);
|
||||
expect(highlighted.highlightNodeId).toBe('Ada');
|
||||
});
|
||||
|
||||
it.each([
|
||||
CompilationTemplateKind.SessionGraph,
|
||||
CompilationTemplateKind.Empty,
|
||||
])('leaves %s on the default graph colors and tooltip', (kind) => {
|
||||
render(<RepresentationRenderer template={{ ...Template, kind }} />);
|
||||
const props = mockGraphRender.mock.calls.at(-1)![0];
|
||||
expect(props.getNodeColor).toBeUndefined();
|
||||
expect(props.getNodeLabel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,10 @@ import {
|
||||
} from './adapters';
|
||||
import MindMapG6Graph from './mindmap-g6-graph';
|
||||
import TimelineX6Graph from './timeline-x6-graph';
|
||||
import {
|
||||
getKnowledgeGraphNodeLabel,
|
||||
useKnowledgeGraphNodeColor,
|
||||
} from './use-knowledge-graph-node-style';
|
||||
|
||||
export interface ClickableNode {
|
||||
id: string;
|
||||
@@ -73,6 +77,11 @@ function UnsupportedPlaceholder({ kind }: { kind: StructureTemplateKind }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a structure template and forwards supported node selections.
|
||||
* Knowledge graphs receive entity-type colors and labels; other graph views
|
||||
* retain the shared defaults.
|
||||
*/
|
||||
export function RepresentationRenderer({
|
||||
template,
|
||||
onNodeClick,
|
||||
@@ -80,6 +89,7 @@ export function RepresentationRenderer({
|
||||
totalEntities,
|
||||
returnedEntities,
|
||||
}: RepresentationRendererProps) {
|
||||
const getKnowledgeGraphNodeColor = useKnowledgeGraphNodeColor();
|
||||
const handleTreeItemClick = useCallback(
|
||||
(item: TreeDataItem | undefined) => {
|
||||
if (item?.source_chunk_ids?.length) {
|
||||
@@ -188,6 +198,8 @@ export function RepresentationRenderer({
|
||||
data={forceGraphData}
|
||||
show
|
||||
getNodeId={getArtifactNodeName}
|
||||
getNodeColor={getKnowledgeGraphNodeColor}
|
||||
getNodeLabel={getKnowledgeGraphNodeLabel}
|
||||
onNodeClick={handleArtifactNodeClick}
|
||||
highlightNodeId={highlightNodeId}
|
||||
totalEntities={totalEntities}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { EntityNodeColor } from '@/components/artifact-force-graph/node-style';
|
||||
import { type IArtifactGraphEntity } from '@/interfaces/database/dataset';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const KnowledgeGraphColorCount = 10;
|
||||
|
||||
/**
|
||||
* Returns plain tooltip text with the original entity type and description.
|
||||
* Blank types display as `other`; the graph handles HTML escaping.
|
||||
*/
|
||||
export const getKnowledgeGraphNodeLabel = (node: IArtifactGraphEntity) => {
|
||||
const type =
|
||||
typeof node.type === 'string' && node.type.trim() ? node.type : 'other';
|
||||
return node.description ? `${type} · ${node.description}` : type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a stable node-color callback backed by the root CSS palette.
|
||||
* Trimmed, lowercase entity types select a color; blank and `other` types use
|
||||
* the neutral token. Different types can share one of the ten palette colors.
|
||||
*/
|
||||
export function useKnowledgeGraphNodeColor() {
|
||||
return useMemo(() => {
|
||||
// These root tokens use the same colors in both themes. Resolve them once,
|
||||
// outside node callbacks, so Canvas and withAlpha receive real hex colors.
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const otherColor =
|
||||
style.getPropertyValue('--graph-node-other').trim() || EntityNodeColor;
|
||||
const colors = Array.from(
|
||||
{ length: KnowledgeGraphColorCount },
|
||||
(_, index) =>
|
||||
style.getPropertyValue(`--graph-node-${index + 1}`).trim() ||
|
||||
otherColor,
|
||||
);
|
||||
|
||||
return (node: IArtifactGraphEntity): string => {
|
||||
// Normalize the color key only. Do not merge synonyms or mutate data.
|
||||
const type =
|
||||
typeof node.type === 'string' ? node.type.trim().toLowerCase() : '';
|
||||
if (!type || type === 'other') return otherColor;
|
||||
|
||||
// FNV-1a over UTF-16 code units: stable across ordering and graph subsets.
|
||||
// Ten slots intentionally allow collisions; the tooltip identifies type.
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < type.length; index++) {
|
||||
hash = Math.imul(hash ^ type.charCodeAt(index), 16777619);
|
||||
}
|
||||
return colors[(hash >>> 0) % KnowledgeGraphColorCount];
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -98,6 +98,18 @@
|
||||
--border-button: rgba(0, 0, 0, 0.1);
|
||||
/* Regulators, parsing, switches, variables */
|
||||
--accent-primary: 0 190 180;
|
||||
/* Categorical graph fills shared by light and dark themes. */
|
||||
--graph-node-1: #4e79a7;
|
||||
--graph-node-2: #b96b1d;
|
||||
--graph-node-3: #c44e52;
|
||||
--graph-node-4: #268985;
|
||||
--graph-node-5: #4e8638;
|
||||
--graph-node-6: #9a7b00;
|
||||
--graph-node-7: #9165a0;
|
||||
--graph-node-8: #b55b89;
|
||||
--graph-node-9: #8a6f55;
|
||||
--graph-node-10: #677d8d;
|
||||
--graph-node-other: #777777;
|
||||
/* Output Variables Box */
|
||||
--bg-accent: rgba(76, 164, 231, 0.05);
|
||||
|
||||
|
||||
Reference in new issue
Block a user