initial commit

This commit is contained in:
Henning Dieterichs
2020-08-12 23:31:12 +02:00
commit 7c4c9348f3
98 changed files with 15500 additions and 0 deletions

View File

@@ -0,0 +1,270 @@
import * as React from "react";
import { observer, disposeOnUnmount } from "mobx-react";
import { observable, autorun, trace, computed } from "mobx";
import * as monacoTypes from "monaco-editor";
import { getLanguageId } from "../monaco-text-visualizer/MonacoEditor";
import {
TreeWithPathView,
TreeViewModel,
TreeNodeViewModel,
} from "../tree-visualizer/Views";
import { sAstTree } from ".";
import Measure from "react-measure";
import { MonacoEditor, getLoadedMonaco } from "@hediet/monaco-editor-react";
import { Theme } from "@hediet/visualization-core";
export interface NodeInfo {
start: number;
length: number;
}
@observer
export class AstTree extends React.Component<{
model: TreeViewModel<NodeInfo>;
data: typeof sAstTree.T;
nodeInfoToRange: (info: NodeInfo) => monacoTypes.IRange;
posToIndex: (pos: monacoTypes.IPosition) => number;
theme: Theme;
}> {
private readonly editorRef = React.createRef<SourceCodeView>();
render() {
const { model, data, theme } = this.props;
let languageId = "text";
if (data.fileName) {
languageId = getLanguageId(data.fileName);
}
return (
<Measure client>
{({ measureRef, contentRect }) => {
return (
<div
ref={measureRef}
className="component-AstTree"
style={{
height: "100%",
display: "flex",
flexDirection:
contentRect.client &&
contentRect.client.width > 1100
? "row"
: "column",
}}
>
<div
className="part-tree2"
style={{ flex: 1, minHeight: 0, minWidth: 0 }}
>
<TreeWithPathView model={model} />
</div>
<div
className="part-editor"
style={{
flex: 1,
minHeight: 0,
minWidth: 0,
height: "100%",
}}
>
<SourceCodeView
theme={theme}
ref={this.editorRef}
nodeInfoToRange={this.props.nodeInfoToRange}
posToIndex={this.props.posToIndex}
model={model}
languageId={languageId}
text={data.text}
/>
</div>
</div>
);
}}
</Measure>
);
}
}
@observer
export class SourceCodeView extends React.Component<{
text: string;
languageId: string;
model: TreeViewModel<NodeInfo>;
theme: Theme;
nodeInfoToRange: (info: NodeInfo) => monacoTypes.IRange;
posToIndex: (pos: monacoTypes.IPosition) => number;
}> {
@observable private editor:
| monacoTypes.editor.IStandaloneCodeEditor
| undefined;
private markedDecorations: string[] = [];
private selectedDecorations: string[] = [];
constructor(props: any) {
super(props);
}
layout() {
if (this.editor) {
this.editor.layout();
}
}
@computed.struct
private get markedRanges(): monacoTypes.IRange[] {
if (!this.editor || !this.model) {
return [];
}
const editorModel = this.model;
const ranges = this.props.model.marked.map(s =>
this.props.nodeInfoToRange(s.data)
);
return ranges;
}
@disposeOnUnmount
private _updateMarkedDecorations = autorun(
() => {
if (!this.editor) {
return;
}
const ranges = this.markedRanges;
this.markedDecorations = this.editor.deltaDecorations(
this.markedDecorations,
ranges.map(range => ({
range,
options: { className: "marked" },
}))
);
if (ranges.length > 0) {
this.editor.revealRange(
ranges[0],
getLoadedMonaco().editor.ScrollType.Smooth
);
}
},
{ name: "updateMarkedDecorations" }
);
@computed.struct
private get selected(): monacoTypes.IRange | undefined {
if (this.editor && this.model) {
const selected = this.props.model.selected;
if (selected) {
const range = this.props.nodeInfoToRange(selected.data);
return range;
}
}
return undefined;
}
@disposeOnUnmount
private _updateSelectedDecoration = autorun(
() => {
console.log("_updateSelectedDecoration");
if (!this.editor) {
return;
}
const range = this.selected;
if (range) {
this.selectedDecorations = this.editor.deltaDecorations(
this.selectedDecorations,
[
{
range,
options: { className: "selected" },
},
]
);
this.editor.revealRange(
range,
getLoadedMonaco().editor.ScrollType.Smooth
);
} else {
this.selectedDecorations = this.editor.deltaDecorations(
this.selectedDecorations,
[]
);
}
},
{ name: "updateDecorations" }
);
@computed.struct
private get modelData(): { text: string; languageId: string } {
const { text, languageId } = this.props;
return { text, languageId };
}
private lastModel: monacoTypes.editor.ITextModel | undefined;
@computed get model(): monacoTypes.editor.ITextModel {
const last = this.lastModel;
this.lastModel = getLoadedMonaco().editor.createModel(
this.modelData.text,
this.modelData.languageId,
undefined
);
if (last) {
setTimeout(() => {
// we don't want to dispose the current model
last.dispose();
});
}
return this.lastModel;
}
render() {
return (
<MonacoEditor
model={this.model}
theme={this.props.theme.kind === "dark" ? "vs-dark" : "vs"}
onEditorLoaded={e => {
this.editor = e;
e.onDidChangeCursorSelection(e => {
const selectionStart = this.props.posToIndex(
e.selection.getStartPosition()
);
const selectionEnd = this.props.posToIndex(
e.selection.getEndPosition()
);
const findNode = (
m: TreeNodeViewModel<NodeInfo>
): TreeNodeViewModel<NodeInfo> | undefined => {
const nodeStart = m.data.start;
const nodeEnd = nodeStart + m.data.length;
if (
!(
nodeStart <= selectionStart &&
selectionEnd <= nodeEnd
)
) {
return undefined;
}
for (const c of m.children) {
const r = findNode(c);
if (r) {
return r;
}
}
return m;
};
if (this.props.model.root) {
const n = findNode(this.props.model.root);
if (n) {
this.props.model.select(n);
}
}
});
}}
readOnly={true}
/>
);
}
}

View File

@@ -0,0 +1,126 @@
import {
Serializer,
sLazy,
sOpenObject,
sArrayOf,
sString,
sOptionalProp,
sUnion,
sLiteral,
sBoolean,
sProp,
sNumber,
} from "@hediet/semantic-json";
import { visualizationNs } from "../../consts";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
import { createTreeViewModelFromTreeNodeData } from "../tree-visualizer";
import { AstTree, NodeInfo } from "./AstVisualizer";
import React from "react";
import LineColumn from "line-column";
import * as monacoTypes from "monaco-editor";
import { getLoadedMonaco } from "@hediet/monaco-editor-react";
interface AstTreeNode {
children: AstTreeNode[];
items: Item[];
segment?: string; // like "root", ".name" or "[0]"
isMarked?: boolean;
span: NodeInfo;
}
interface Item {
text: string;
emphasis?: "style1" | "style2" | "style3" | string;
}
export const sAstTreeNode: Serializer<AstTreeNode> = sLazy(() =>
sOpenObject({
children: sArrayOf(sAstTreeNode),
items: sArrayOf(
sOpenObject({
text: sString(),
emphasis: sOptionalProp(
sUnion(
[
sLiteral("style1"),
sLiteral("style2"),
sLiteral("style3"),
sString(),
],
{ inclusive: true }
)
),
}).defineAs(visualizationNs("TreeNodeItem"))
),
segment: sOptionalProp(sString()),
isMarked: sOptionalProp(sBoolean()),
span: sOpenObject({
start: sNumber(),
length: sNumber(),
}),
}).defineAs(visualizationNs("TreeNode"))
);
export const sAstTree = sOpenObject({
kind: sOpenObject({
tree: sLiteral(true),
text: sLiteral(true),
}),
root: sAstTreeNode,
text: sString(),
fileName: sOptionalProp(sString()),
});
export const astVisualizer = createVisualizer({
id: "ast",
name: "AST",
serializer: sAstTree,
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1500 }, theme => {
const m = createTreeViewModelFromTreeNodeData(
data.root,
node => node.span
);
const l = LineColumn(data.text);
function translatePosition(pos: number): monacoTypes.IPosition {
let r = l.fromIndex(pos);
if (!r) {
r = l.fromIndex(data.text.length - 1);
r.col++;
}
return {
column: r.col,
lineNumber: r.line,
};
}
const nodeInfoToRange = (info: NodeInfo): monacoTypes.IRange => {
const start = translatePosition(info.start);
const end = translatePosition(info.start + info.length);
const range = getLoadedMonaco().Range.fromPositions(start, end);
return range;
};
function posToIndex(pos: monacoTypes.IPosition): number {
const i = l.toIndex(pos.lineNumber, pos.column);
if (i == -1) {
return data.text.length;
}
return i;
}
return (
<AstTree
model={m}
theme={theme}
data={data}
nodeInfoToRange={nodeInfoToRange}
posToIndex={posToIndex}
/>
);
}),
});
globalVisualizationFactory.addVisualizer(astVisualizer);

View File

@@ -0,0 +1,23 @@
.theme-dark {
--visualizer-ast-marked-background: rgba(234, 92, 0, 0.33);
--visualizer-ast-selected-background: #515c6a;
}
.theme-light {
--visualizer-ast-marked-background: orange;
--visualizer-ast-selected-background: yellow;
}
.component-AstTree {
.part-editor {
//border-top: solid gray 1px;
.marked {
background: var(--visualizer-ast-marked-background);
}
.selected {
background: var(--visualizer-ast-selected-background);
opacity: 0.7;
}
}
}

View File

@@ -0,0 +1,13 @@
declare module "line-column" {
export = function(
text: string
): {
fromIndex(
idx: number
): {
line: number;
col: number;
};
toIndex(line: number, column: number): number;
} {};
}

View File

@@ -0,0 +1,49 @@
import { observer, disposeOnUnmount } from "mobx-react";
import * as React from "react";
import { observable, autorun, runInAction } from "mobx";
import { SvgViewer } from "../../svg-visualizer/SvgViewer";
class VisLoader {
private result: any | undefined;
async getViz(): Promise<any> {
if (!this.result) {
const Viz = await import("viz.js");
const { Module, render } = await import("viz.js/full.render.js");
const viz = new Viz.default({
Module: () => Module({ TOTAL_MEMORY: 1 << 30 }),
render,
});
this.result = viz;
}
return this.result;
}
}
const vizLoader = new VisLoader();
@observer
export class GraphvizDotViewer extends React.Component<{
dotCode: string;
svgRef?: (element: SVGSVGElement | null) => void;
}> {
@observable private svg: string | null = null;
@disposeOnUnmount
// @ts-ignore
private readonly _updateSvgAutorun = autorun(async () => {
const dotCode = this.props.dotCode; // to trigger mobx dependency
const viz = await vizLoader.getViz();
const svg = await viz.renderString(dotCode);
runInAction("Update svg", () => (this.svg = svg));
});
render() {
if (!this.svg) {
return <div>Loading...</div>;
}
return <SvgViewer svgRef={this.props.svgRef} svgContent={this.svg} />;
}
}

View File

@@ -0,0 +1,25 @@
import React from "react";
import { sOpenObject, sLiteral, sString } from "@hediet/semantic-json";
import { GraphvizDotViewer } from "./GraphvizDotVisualizer";
import {
ReactVisualization,
globalVisualizationFactory,
createVisualizer,
} from "@hediet/visualization-core";
export const graphvizDotVisualizer = createVisualizer({
id: "graphviz-dot",
name: "Graphviz (Dot Data)",
serializer: sOpenObject({
kind: sOpenObject({
dotGraph: sLiteral(true),
}),
text: sString(),
}),
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1500 }, () => (
<GraphvizDotViewer dotCode={data.text} />
)),
});
globalVisualizationFactory.addVisualizer(graphvizDotVisualizer);

View File

@@ -0,0 +1,9 @@
declare module "viz.js" {
const x: any;
export default x;
}
declare module "viz.js/full.render.js" {
export const render: any;
export const Module: any;
}

View File

@@ -0,0 +1,36 @@
import { observer } from "mobx-react";
import * as React from "react";
import { GraphvizDotViewer } from "../dot-graphviz-visualizer/GraphvizDotVisualizer";
@observer
export class GraphvizGraphViewer extends React.Component<{
nodes: { id: string; label?: string }[];
edges: { from: string; to: string; label?: string }[];
}> {
render() {
const { nodes, edges } = this.props;
const dotContent = `
digraph MyGraph {
${nodes
.map(
n =>
`"${n.id}" [ label = ${JSON.stringify(
n.label !== undefined ? n.label : n.id
)} ];`
)
.join("\n ")}
${edges
.map(
e =>
`"${e.from}" -> "${
e.to
}" [ label = ${JSON.stringify(
e.label !== undefined ? e.label : ""
)} ];`
)
.join("\n")}
}
`;
return <GraphvizDotViewer dotCode={dotContent} />;
}
}

View File

@@ -0,0 +1,20 @@
import React from "react";
import { GraphvizGraphViewer } from "./GraphvizGraphVisualizer";
import { sGraph } from "../sGraph";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
export const graphvizGraphVisualizer = createVisualizer({
id: "graphviz-graph",
name: "Graphviz",
serializer: sGraph,
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1000 }, () => (
<GraphvizGraphViewer edges={data.edges} nodes={data.nodes} />
)),
});
globalVisualizationFactory.addVisualizer(graphvizGraphVisualizer);

View File

@@ -0,0 +1,96 @@
import { Network, Options, DataSet } from "vis-network/standalone";
import { observer } from "mobx-react";
import * as React from "react";
import { NodeGraphData, EdgeGraphData } from "../sGraph";
@observer
export class VisJsGraphViewer extends React.Component<{
nodes: NodeGraphData[];
edges: EdgeGraphData[];
}> {
private readonly divRef = React.createRef<HTMLDivElement>();
private readonly nodes = new DataSet<{
id: string;
label?: string;
color?: string;
shape?: string;
}>();
private readonly edges = new DataSet<{
id: string;
from: string;
to: string;
label?: string;
color?: string;
dashes?: boolean;
shape?: boolean;
}>();
render() {
return <div style={{ height: "100%" }} ref={this.divRef} />;
}
synchronizeData() {
const newNodes = new Set<string>();
for (const n of this.props.nodes) {
newNodes.add(n.id);
this.nodes.update({
id: n.id,
label: n.label !== undefined ? n.label : n.id,
color: n.color,
shape: n.shape,
});
}
this.nodes.forEach(item => {
if (!newNodes.has(item.id)) {
this.nodes.remove(item);
}
});
function getIdOfEdge(e: EdgeGraphData): string {
if (e.id) {
return e.id;
}
return e.from + "####" + e.to + "|" + e.label;
}
const newEdges = new Set<string>();
for (const n of this.props.edges) {
const id = getIdOfEdge(n);
newEdges.add(id);
this.edges.update({
id: id,
label: n.label !== undefined ? n.label : "",
from: n.from,
to: n.to,
color: n.color,
dashes: n.dashes,
});
}
this.edges.forEach(item => {
if (!newEdges.has(item.id)) {
this.edges.remove(item);
}
});
}
componentDidUpdate() {
this.synchronizeData();
}
componentDidMount() {
this.synchronizeData();
const data = {
nodes: this.nodes,
edges: this.edges,
};
const options: Options = {
edges: {
arrows: {
to: { enabled: true, scaleFactor: 1, type: "arrow" },
},
},
};
const network = new Network(this.divRef.current!, data, options);
}
}

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import { makeLazyLoadable } from "../../../utils/LazyLoadable";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
import { sGraph } from "../sGraph";
const VisJsGraphViewerLazyLoadable = makeLazyLoadable(
async () => (await import("./VisJsGraphViewer")).VisJsGraphViewer
);
export const visJsGraphVisualizer = createVisualizer({
id: "vis.js-graph",
name: "vis.js",
serializer: sGraph,
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1500 }, () => (
<VisJsGraphViewerLazyLoadable
edges={data.edges}
nodes={data.nodes}
/>
)),
});
globalVisualizationFactory.addVisualizer(visJsGraphVisualizer);

View File

@@ -0,0 +1,3 @@
export * from "./dot-graphviz-visualizer";
export * from "./graph-graphviz-visualizer";
export * from "./graph-visjs-visualizer";

View File

@@ -0,0 +1,35 @@
import {
sOpenObject,
sLiteral,
sArrayOf,
sString,
sOptionalProp,
sUnion,
sBoolean,
} from "@hediet/semantic-json";
import { visualizationNs } from "../../consts";
export type NodeGraphData = typeof sGraphNode.T;
export type EdgeGraphData = typeof sGraphEdge.T;
export const sGraphNode = sOpenObject({
id: sString(),
label: sOptionalProp(sString(), {}),
color: sOptionalProp(sString(), {}),
shape: sOptionalProp(sUnion([sLiteral("ellipse"), sLiteral("box")]), {}),
}).defineAs(visualizationNs("GraphNode"));
export const sGraphEdge = sOpenObject({
from: sString(),
to: sString(),
label: sOptionalProp(sString(), {}),
id: sOptionalProp(sString(), {}),
color: sOptionalProp(sString(), {}),
dashes: sOptionalProp(sBoolean(), {}),
}).defineAs(visualizationNs("GraphEdge"));
export const sGraph = sOpenObject({
kind: sOpenObject({ graph: sLiteral(true) }),
nodes: sArrayOf(sGraphNode),
edges: sArrayOf(sGraphEdge),
}).defineAs(visualizationNs("Graph"));

View File

@@ -0,0 +1,377 @@
import * as React from "react";
import { observer } from "mobx-react";
import { computed, action, observable } from "mobx";
import { Icon } from "@blueprintjs/core";
import { sGrid } from ".";
export class DecoratedGridComponent extends React.Component<{
data: typeof sGrid.T;
}> {
render() {
const d = this.props.data;
const map = new Set<string>();
function getUniqueId(tag: string): string {
let n = 0;
while (true) {
const id = tag + (n === 0 ? "" : `-${n}`);
if (!map.has(id)) {
map.add(id);
return id;
}
n++;
}
}
const rows: GridComponent["props"]["rows"] = [];
rows.push();
let rowIdx = 0;
let columnCount = 0;
for (const row of d.rows) {
columnCount = Math.max(columnCount, row.columns.length);
rows.push({
columns: row.columns.map((c, colIdx) => ({
content: (
<Cell>
{c.content !== undefined ? c.content : c.tag || ""}
</Cell>
),
id:
c.tag !== undefined
? getUniqueId(c.tag)
: `${rowIdx}-${colIdx}`,
color: c.color,
kind: "data",
})),
});
rowIdx++;
}
rows.unshift({
columns: [...new Array(columnCount)].map((v, idx) => ({
id: `column-header-${idx}`,
content: <Cell>{idx}</Cell>,
kind: "header",
})),
});
if (d.markers) {
for (const m of d.markers) {
rows.push({
columns: [],
});
const r = rows[rows.length - 1];
for (let i = 0; i < m.column; i++) {
r.columns.push({
content: "",
kind: "empty",
id: `marker-spacer-${i}`,
});
}
r.columns.push({
id: m.id,
kind: "empty",
content: (
<Cell>
<Icon
icon="arrow-up"
color="var(--vscode-editor-foreground)"
/>
{m.label || m.id}
</Cell>
),
});
}
}
return (
<div
style={{
height: "100%",
justifyContent: "center",
alignItems: "center",
padding: 10,
display: "flex",
}}
>
<GridComponent rows={rows} markers={[]} />
</div>
);
}
}
function Cell(props: { children: React.ReactNode }) {
return (
<div
style={{
padding: 10,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{props.children}
</div>
);
}
interface CellData {
content: React.ReactNode;
id: string;
color?: string;
kind: "data" | "header" | "empty";
}
@observer
class GridComponent extends React.Component<{
rows: {
columns: CellData[];
}[];
markers: {
id: string;
row: number;
column: number;
rows?: number;
columns?: number;
label?: string;
color?: string;
}[];
}> {
private readonly cells = new Map<string, CellInfo>();
@computed get cellsGrid(): { maxWidth: number; grid: CellInfo[][] } {
const unusedIds = new Set(this.cells.keys());
const rows = this.props.rows;
const grid = new Array<Array<CellInfo>>();
let maxWidth = 0;
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
const row = rows[rowIdx];
maxWidth = Math.max(maxWidth, row.columns.length);
const cellRow = new Array<CellInfo>();
grid.push(cellRow);
for (let colIdx = 0; colIdx < row.columns.length; colIdx++) {
const col = row.columns[colIdx];
const key = col.id;
unusedIds.delete(key);
let c = this.cells.get(key);
if (!c) {
// New cell
c = new CellInfo(col);
this.cells.set(key, c);
} else {
c.updateCellData(col);
}
cellRow.push(c);
}
}
for (const id in unusedIds.values()) {
this.cells.delete(id);
}
return { maxWidth, grid };
}
@computed
private get layout(): {
cells: {
cell: CellInfo;
top: number;
left: number;
width: number;
height: number;
}[];
width: number;
height: number;
} {
const { maxWidth, grid } = this.cellsGrid;
const maxWidths = new Array<number>();
for (let colIdx = 0; colIdx < maxWidth; colIdx++) {
let maxWidth = 30;
for (const row of grid) {
const col = row[colIdx];
if (col) {
maxWidth = Math.max(maxWidth, col.contentWidth || 0);
}
}
maxWidths.push(maxWidth);
}
const result = new Array<{
cell: CellInfo;
top: number;
left: number;
width: number;
height: number;
}>();
let width = 0;
let height = 0;
let top = 0;
let left = 0;
for (const row of grid) {
let maxHeight = 0;
for (const col of row) {
maxHeight = Math.max(maxHeight, col.contentHeight || 0);
}
for (let colIdx = 0; colIdx < row.length; colIdx++) {
const maxWidth = maxWidths[colIdx];
const col = row[colIdx];
result.push({
cell: col,
top,
left,
height: maxHeight,
width: maxWidth,
});
height = Math.max(height, top + maxHeight);
width = Math.max(width, left + maxWidth);
col.lastTop = col.top;
col.lastLeft = col.left;
col.top = top;
col.left = left;
col.lastContentArea = col.contentArea;
col.contentArea = maxHeight * maxWidth;
left += maxWidth;
}
left = 0;
top += maxHeight;
}
result.sort((a, b) => a.cell.id.localeCompare(b.cell.id));
return { cells: result, height, width };
}
render() {
const l = this.layout;
return (
<div
className="component-Grid"
style={{
position: "relative",
height: l.height,
width: l.width,
}}
>
{l.cells.map(i => (
<div
key={i.cell.id}
style={{
position: "absolute",
top: i.top,
left: i.left,
height: i.height,
width: i.width,
zIndex: i.cell.distance,
transition:
i.cell.lastContentArea !== 0 ? "all 1s" : "",
}}
>
<div
className={`part-${i.cell.kind}`}
style={
{
empty: {},
data: {
display: "flex",
overflow: "hidden",
justifyContent: "center",
margin: 2,
},
header: {
overflow: "hidden",
display: "flex",
justifyContent: "center",
margin: 2,
},
}[i.cell.kind]
}
>
<div
ref={i.cell.handleRef}
style={{
width: "fit-content",
height: "fit-content",
}}
>
{i.cell.content}
</div>
</div>
</div>
))}
</div>
);
}
componentDidMount() {
this.updateContentSize();
}
componentDidUpdate() {
this.updateContentSize();
}
@action
updateContentSize() {
for (const c of this.cells.values()) {
if (c.ref) {
const r = c.ref.getBoundingClientRect();
c.contentHeight = r.height + 6;
c.contentWidth = r.width + 4;
}
}
}
}
class CellInfo {
@observable public contentWidth: number | undefined = undefined;
@observable public contentHeight: number | undefined = undefined;
public contentArea = 0;
public lastContentArea = 0;
public lastTop = 0;
public top = 0;
public lastLeft = 0;
public left = 0;
public get distance(): number {
return Math.floor(
Math.pow(this.top - this.lastTop, 2) +
Math.pow(this.left - this.lastLeft, 2)
);
}
content!: React.ReactNode;
ref: HTMLDivElement | null = null;
public readonly id: string;
public readonly kind: CellData["kind"];
constructor(cellData: CellData) {
this.id = cellData.id;
this.kind = cellData.kind;
this.updateCellData(cellData);
}
public updateCellData(cellData: CellData) {
this.content = cellData.content;
}
public readonly handleRef = (ref: HTMLDivElement | null) => {
this.ref = ref;
};
}

View File

@@ -0,0 +1,70 @@
import React from "react";
import {
sOpenObject,
sLiteral,
sString,
sArrayOf,
sObject,
sOptionalProp,
sNumber,
} from "@hediet/semantic-json";
import { DecoratedGridComponent } from "./GridVisualizer";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
export const sGrid = sOpenObject({
kind: sOpenObject({
array: sLiteral(true),
}),
columnLabels: sArrayOf(
sOpenObject({
label: sOptionalProp(sString(), {}),
})
),
rows: sArrayOf(
sOpenObject({
label: sOptionalProp(sString(), {}),
columns: sArrayOf(
sOpenObject({
content: sOptionalProp(sString(), {}),
tag: sOptionalProp(sString(), {
description:
"A value to identify this cell. Should be unique.",
}),
color: sOptionalProp(sString(), {}),
})
),
})
),
markers: sOptionalProp(
sArrayOf(
sOpenObject({
id: sString(),
row: sNumber(),
column: sNumber(),
rows: sOptionalProp(sNumber(), {}),
columns: sOptionalProp(sNumber(), {}),
label: sOptionalProp(sString(), {}),
color: sOptionalProp(sString(), {}),
})
),
{}
),
});
export const gridVisualizer = createVisualizer({
id: "grid",
name: "Grid",
serializer: sGrid,
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1000 }, () => (
<DecoratedGridComponent data={data} />
)),
});
globalVisualizationFactory.addVisualizer(gridVisualizer);

View File

@@ -0,0 +1,14 @@
.component-Grid {
.part-data {
border: 1px solid lightgray;
background: #eeeeee;
}
.part-header {
background: transparentize(white, 0.5);
}
.part-empty {
color: var(--vscode-editor-foreground);
}
}

View File

@@ -0,0 +1,70 @@
import { sOpenObject, sLiteral, sString } from "@hediet/semantic-json";
import React from "react";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
import { SvgViewer } from "../svg-visualizer/SvgViewer";
import { Observer, observer, disposeOnUnmount } from "mobx-react";
import { observable, autorun } from "mobx";
export const imageVisualizer = createVisualizer({
id: "image",
name: "Image",
serializer: sOpenObject({
kind: sOpenObject({
imagePng: sLiteral(true),
}),
base64Data: sString(),
}),
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1000 }, () => {
return <ImageViewer base64Data={data.base64Data} />;
}),
});
@observer
class ImageViewer extends React.Component<{ base64Data: string }> {
@observable private svg: string | undefined = undefined;
@disposeOnUnmount
private readonly updateSvg = autorun(async () => {
const src = `data:image/png;base64,${this.props.base64Data}`;
const { width, height } = await new Promise<{
width: number;
height: number;
}>(res => {
const img = new Image();
img.onload = function() {
res({ width: img.width, height: img.height });
};
img.src = src;
});
this.svg = `
<svg viewBox="0 0 ${width} ${height}">
<defs>
<pattern id="Pattern" x="0" y="0" width="14" height="14" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="7" height="7" style="fill: lightgray; stroke-width: 0" />
<rect x="7" y="7" width="7" height="7" style="fill: lightgray; stroke-width: 0" />
</pattern>
</defs>
<rect fill="url(#Pattern)" style="stroke-width: 0" width="${width}" height="${height}"/>
<image style="image-rendering: pixelated" xlink:href="${src}" />
</svg>`;
});
render() {
if (!this.svg) {
return null;
}
return <SvgViewer svgContent={this.svg} />;
}
}
globalVisualizationFactory.addVisualizer(imageVisualizer);

View File

@@ -0,0 +1,11 @@
export * from "./ast-visualizer";
export * from "./graph";
export * from "./grid-visualizer";
export * from "./image-visualizer";
export * from "./monaco-text-visualizer";
export * from "./perspective-table-visualizer";
export * from "./plotly-visualizer";
export * from "./simple-text-visualizer";
export * from "./source-visualizer";
export * from "./svg-visualizer";
export * from "./tree-visualizer";

View File

@@ -0,0 +1,103 @@
import React from "react";
import { observer, disposeOnUnmount } from "mobx-react";
import { observable, autorun } from "mobx";
import * as monacoTypes from "monaco-editor";
import { Theme } from "@hediet/visualization-core";
import { getLoadedMonaco } from "@hediet/monaco-editor-react";
export function getLanguageId(fileName: string): string {
const l = getLoadedMonaco().languages.getLanguages();
const result = l.find(l => {
if (l.filenamePatterns) {
for (const p of l.filenamePatterns) {
if (new RegExp(p).test(fileName)) {
return true;
}
}
}
if (l.extensions) {
for (const p of l.extensions) {
if (fileName.endsWith(p)) {
return true;
}
}
}
return false;
});
if (result) {
return result.id;
}
return "text";
}
@observer
export class MonacoEditor extends React.Component<{
text: string;
languageId: string;
theme: Theme;
}> {
@observable private editor:
| monacoTypes.editor.IStandaloneCodeEditor
| undefined;
componentWillUnmount() {
if (this.editor) {
this.editor.dispose();
}
}
private model: monacoTypes.editor.ITextModel | undefined = undefined;
@disposeOnUnmount
private _updateText = autorun(() => {
if (this.editor) {
const model = getLoadedMonaco().editor.createModel(
this.props.text,
this.props.languageId,
undefined
);
this.editor.setModel(model);
/* this.editor!.updateOptions({ readOnly: false });
setTimeout(() => {
const a = this.editor!.getAction(
"editor.action.formatDocument"
);
a.run().then(() => {
this.editor!.updateOptions({ readOnly: true });
});
}, 200);*/
if (this.model) {
this.model.dispose();
}
this.model = model;
}
});
private readonly setEditorDiv = (editorDiv: HTMLDivElement) => {
if (!editorDiv) {
return;
}
this.editor = getLoadedMonaco().editor.create(editorDiv, {
model: null,
automaticLayout: true,
scrollBeyondLastLine: false,
minimap: { enabled: false },
fixedOverflowWidgets: true,
readOnly: true,
theme: this.props.theme.kind === "dark" ? "vs-dark" : "vs",
});
};
render() {
return (
<div className="component-monaco-editor">
<div className="part-editor" ref={this.setEditorDiv} />
</div>
);
}
}

View File

@@ -0,0 +1,40 @@
import {
sOpenObject,
sLiteral,
sString,
sOptionalProp,
} from "@hediet/semantic-json";
import React from "react";
import { getLanguageId, MonacoEditor } from "./MonacoEditor";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
export const monacoTextVisualizer = createVisualizer({
id: "monaco-text",
name: "Text",
serializer: sOpenObject({
kind: sOpenObject({
text: sLiteral(true),
}),
text: sString(),
fileName: sOptionalProp(sString(), {
description:
"An optional filename that might be used for chosing a syntax highlighter",
}),
}),
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 500 }, theme => {
let id = "text";
if (data.fileName) {
id = getLanguageId(data.fileName);
}
return (
<MonacoEditor text={data.text} languageId={id} theme={theme} />
);
}),
});
globalVisualizationFactory.addVisualizer(monacoTextVisualizer);

View File

@@ -0,0 +1,22 @@
.component-monaco-editor {
width: 100%;
height: 100%;
min-width: 0;
box-sizing: border-box;
display: flex;
padding: 2px;
border: 0.5px solid gray;
.part-editor {
min-width: 0;
min-height: 0;
flex: 1;
.current-line {
border: none !important;
}
.selectionHighlight {
background: transparent !important;
}
}
}

View File

@@ -0,0 +1,76 @@
import React from "react";
import { sOpenObject, sLiteral, sArrayOf, sAny } from "@hediet/semantic-json";
import { toJS } from "mobx";
import "@finos/perspective-viewer";
import "@finos/perspective-viewer-datagrid";
import "@finos/perspective-viewer-d3fc";
import "@finos/perspective-viewer/themes/all-themes.css";
import { HTMLPerspectiveViewerElement } from "@finos/perspective-viewer";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
export const sTable = sOpenObject({
kind: sOpenObject({
table: sLiteral(true),
}),
rows: sArrayOf(
sAny() // object
),
});
export const perspectiveTableVisualizer = createVisualizer({
id: "perspective-table",
name: "Perspective Table",
serializer: sTable,
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1000 }, theme => (
<PerspectiveDataViewer
className={
{
light: "perspective-viewer-material",
dark: "perspective-viewer-material-dark",
}[theme.kind]
}
style={{
width: "100%",
height: "100%",
}}
data={toJS(data.rows)}
/>
)),
});
class PerspectiveDataViewer extends React.Component<
{ data: object[] } & React.HTMLAttributes<HTMLDivElement>
> {
private readonly nodeRef = React.createRef<HTMLPerspectiveViewerElement>();
componentDidUpdate() {
this.nodeRef.current!.load(this.props.data);
}
componentDidMount() {
setTimeout(() => {
// TODO: Why timeout?
// It does not work without it.
this.nodeRef.current!.load(this.props.data);
this.nodeRef.current!.toggleConfig();
});
}
render() {
return (
<perspective-viewer
class={this.props.className}
style={this.props.style}
ref={this.nodeRef}
/>
);
}
}
globalVisualizationFactory.addVisualizer(perspectiveTableVisualizer);

View File

@@ -0,0 +1,187 @@
import { observer } from "mobx-react";
import * as React from "react";
import Plot from "react-plotly.js";
@observer
export class PlotlyViewer extends React.Component<{
data: Plotly.Data[];
layout?: Partial<Plotly.Layout>;
theme: "light" | "dark";
}> {
render() {
const { theme, data, layout } = this.props;
return (
<Plot
style={{ width: "100%", height: "100%" }}
data={data}
layout={Object.assign(
{},
theme === "dark" ? darkLayout : {},
layout || {}
)}
config={{ responsive: true }}
/>
);
}
}
// extracted from the plotly website
const darkLayout: Partial<Plotly.Layout> = {
colorway: [
"#636efa",
"#EF553B",
"#00cc96",
"#ab63fa",
"#19d3f3",
"#e763fa",
"#fecb52",
"#ffa15a",
"#ff6692",
"#b6e880",
],
autosize: true,
//margin: { b: 0, t: 0, pad: 0 },
//height: 300,
/*colorscale: {
sequential: [
[0, "#0508b8"],
[0.0893854748603352, "#1910d8"],
[0.1787709497206704, "#3c19f0"],
[0.2681564245810056, "#6b1cfb"],
[0.3575418994413408, "#981cfd"],
[0.44692737430167595, "#bf1cfd"],
[0.5363128491620112, "#dd2bfd"],
[0.6256983240223464, "#f246fe"],
[0.7150837988826816, "#fc67fd"],
[0.8044692737430168, "#fe88fc"],
[0.8938547486033519, "#fea5fd"],
[0.9832402234636871, "#febefe"],
[1, "#fec3fe"],
],
sequentialminus: [
[0, "#0508b8"],
[0.0893854748603352, "#1910d8"],
[0.1787709497206704, "#3c19f0"],
[0.2681564245810056, "#6b1cfb"],
[0.3575418994413408, "#981cfd"],
[0.44692737430167595, "#bf1cfd"],
[0.5363128491620112, "#dd2bfd"],
[0.6256983240223464, "#f246fe"],
[0.7150837988826816, "#fc67fd"],
[0.8044692737430168, "#fe88fc"],
[0.8938547486033519, "#fea5fd"],
[0.9832402234636871, "#febefe"],
[1, "#fec3fe"],
],
diverging: [
[0, "#8e0152"],
[0.1, "#c51b7d"],
[0.2, "#de77ae"],
[0.3, "#f1b6da"],
[0.4, "#fde0ef"],
[0.5, "#f7f7f7"],
[0.6, "#e6f5d0"],
[0.7, "#b8e186"],
[0.8, "#7fbc41"],
[0.9, "#4d9221"],
[1, "#276419"],
],
},*/
font: { color: "#f2f5fa" },
title: { x: 0.05 },
paper_bgcolor: "rgb(17,17,17)",
plot_bgcolor: "rgb(17,17,17)",
hovermode: "closest",
polar: {
bgcolor: "rgb(17,17,17)",
angularaxis: {
gridcolor: "#506784",
linecolor: "#506784",
ticks: "",
},
radialaxis: {
gridcolor: "#506784",
linecolor: "#506784",
ticks: "",
},
},
ternary: {
bgcolor: "rgb(17,17,17)",
aaxis: { gridcolor: "#506784", linecolor: "#506784", ticks: "" },
baxis: { gridcolor: "#506784", linecolor: "#506784", ticks: "" },
caxis: { gridcolor: "#506784", linecolor: "#506784", ticks: "" },
},
xaxis: {
gridcolor: "#283442",
linecolor: "#506784",
ticks: "",
zerolinecolor: "#283442",
zerolinewidth: 2,
automargin: true,
},
yaxis: {
gridcolor: "#283442",
linecolor: "#506784",
ticks: "",
zerolinecolor: "#283442",
zerolinewidth: 2,
automargin: true,
},
scene: {
xaxis: {
backgroundcolor: "rgb(17,17,17)",
gridcolor: "#506784",
linecolor: "#506784",
showbackground: true,
ticks: "",
zerolinecolor: "#C8D4E3",
gridwidth: 2,
},
yaxis: {
backgroundcolor: "rgb(17,17,17)",
gridcolor: "#506784",
linecolor: "#506784",
showbackground: true,
ticks: "",
zerolinecolor: "#C8D4E3",
gridwidth: 2,
},
zaxis: {
backgroundcolor: "rgb(17,17,17)",
gridcolor: "#506784",
linecolor: "#506784",
showbackground: true,
ticks: "",
zerolinecolor: "#C8D4E3",
gridwidth: 2,
},
},
/*
shapedefaults: {
fillcolor: "#f2f5fa",
line: { width: 0 },
opacity: 0.4,
},
annotationdefaults: {
arrowcolor: "#f2f5fa",
arrowhead: 0,
arrowwidth: 1,
},*/
geo: {
bgcolor: "rgb(17,17,17)",
landcolor: "rgb(17,17,17)",
subunitcolor: "#506784",
showland: true,
showlakes: true,
lakecolor: "rgb(17,17,17)",
},
/*
updatemenudefaults: { bgcolor: "#506784", borderwidth: 0 },
sliderdefaults: {
bgcolor: "#C8D4E3",
borderwidth: 1,
bordercolor: "rgb(17,17,17)",
tickwidth: 0,
},
*/
};

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import {
sOpenObject,
sLiteral,
sArrayOf,
sOptionalProp,
sAny,
sProp,
} from "@hediet/semantic-json";
import { makeLazyLoadable } from "../../utils/LazyLoadable";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
const PlotlyViewerLazyLoadable = makeLazyLoadable(
async () => (await import("./PlotlyViewer")).PlotlyViewer
);
export const plotlyVisualizer = createVisualizer({
id: "plotly",
name: "plotly",
serializer: sOpenObject({
kind: sOpenObject({
plotly: sLiteral(true),
}),
data: sProp(sArrayOf(sAny()), {
description:
"Expecting Plotly.Data[] (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/795ce172038dbafcb9cba030d637d733a7eea19c/types/plotly.js/index.d.ts#L1036)",
}),
layout: sOptionalProp(sAny(), {
description:
"Expecting Partial<Plotly.Layout> (https://github.com/DefinitelyTyped/DefinitelyTyped/blob/795ce172038dbafcb9cba030d637d733a7eea19c/types/plotly.js/index.d.ts#L329)",
}),
}),
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1000 }, theme => (
<PlotlyViewerLazyLoadable
data={data.data}
layout={data.layout}
theme={theme.kind}
/>
)),
});
globalVisualizationFactory.addVisualizer(plotlyVisualizer);

View File

@@ -0,0 +1,33 @@
import { sOpenObject, sLiteral, sString } from "@hediet/semantic-json";
import React from "react";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
export const simpleTextVisualizer = createVisualizer({
id: "simple-text",
name: "Simple Text",
serializer: sOpenObject({
kind: sOpenObject({
text: sLiteral(true),
}),
text: sString(),
}),
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 100 }, () => (
<pre
className="visualizer-simple-text"
style={{
margin: 0,
padding: 10,
color: "var(--visualizer-simple-text-color)",
}}
>
{data.text}
</pre>
)),
});
globalVisualizationFactory.addVisualizer(simpleTextVisualizer);

View File

@@ -0,0 +1,6 @@
.theme-dark {
--visualizer-simple-text-color: white;
}
.theme-light {
--visualizer-simple-text-color: black;
}

View File

@@ -0,0 +1,24 @@
import { sAny } from "@hediet/semantic-json";
import React from "react";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
import { MonacoEditor } from "../monaco-text-visualizer/MonacoEditor";
export const sourceVisualizer = createVisualizer({
id: "source",
name: "JSON Source",
serializer: sAny(),
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: -100 }, theme => (
<MonacoEditor
text={JSON.stringify(data, undefined, 4)}
languageId={"json"}
theme={theme}
/>
)),
});
globalVisualizationFactory.addHiddenVisualizer(sourceVisualizer);

View File

@@ -0,0 +1,6 @@
.theme-dark {
--visualizer-simple-text-color: white;
}
.theme-light {
--visualizer-simple-text-color: black;
}

View File

@@ -0,0 +1,115 @@
import Measure, { ContentRect } from "react-measure";
import { observer } from "mobx-react";
import * as React from "react";
import { observable, action } from "mobx";
import { Tool, ReactSVGPanZoom, Value } from "react-svg-pan-zoom";
function widthOrDefault(r: ContentRect): number {
if (r.bounds && r.bounds.width) {
return r.bounds.width;
}
return 1;
}
function heightOrDefault(r: ContentRect): number {
if (r.bounds && r.bounds.height) {
return r.bounds.height;
}
return 1;
}
@observer
export class SvgViewer extends React.Component<{
svgContent: string;
svgRef?: (element: SVGSVGElement | null) => void;
}> {
@observable tool: Tool = "pan";
@action.bound
private setTool(tool: Tool): void {
this.tool = tool;
}
private readonly svgPanZoomRef = React.createRef<ReactSVGPanZoom>();
componentDidMount() {
const element = this.svgPanZoomRef.current;
if (this.props.svgRef) {
if (!element) {
this.props.svgRef(null);
return;
}
const svg = (element as any).ViewerDOM as SVGSVGElement;
this.props.svgRef(svg);
}
}
@observable value: Value | {} = {};
render() {
let { svgContent } = this.props;
let width: number = 0;
let height: number = 0;
svgContent = svgContent.replace(
/viewBox="[0-9\.]+ [0-9\.]+ ([0-9\.]+) ([0-9\.]+)"/,
(r, w, h) => {
width = parseFloat(w);
height = parseFloat(h);
return "";
}
);
const tool = this.tool;
const val = this.value;
return (
<Measure
bounds
onResize={() => {
if (this.svgPanZoomRef.current) {
(this.svgPanZoomRef.current.fitToViewer as any)(
"center",
"center"
);
}
}}
>
{({ measureRef, contentRect }) => (
<div
ref={measureRef}
className="svgViewer"
style={{ width: "100%", height: "100%" }}
>
<ReactSVGPanZoom
width={widthOrDefault(contentRect)}
height={heightOrDefault(contentRect)}
tool={tool}
value={val as any}
onChangeValue={v => (this.value = v)}
onChangeTool={this.setTool}
ref={this.svgPanZoomRef}
toolbarProps={{
SVGAlignX: "center",
SVGAlignY: "center",
}}
miniatureProps={{
position: "none",
height: 0,
width: 0,
background: "black",
}}
>
<svg width={width} height={height}>
<g
dangerouslySetInnerHTML={{
__html: svgContent,
}}
/>
</svg>
</ReactSVGPanZoom>
</div>
)}
</Measure>
);
}
}

View File

@@ -0,0 +1,25 @@
import React from "react";
import { sOpenObject, sLiteral, sString } from "@hediet/semantic-json";
import { SvgViewer } from "./SvgViewer";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
export const svgVisualizer = createVisualizer({
id: "svg",
name: "Svg",
serializer: sOpenObject({
kind: sOpenObject({
svg: sLiteral(true),
}),
text: sString(),
}),
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1500 }, () => (
<SvgViewer svgContent={data.text} />
)),
});
globalVisualizationFactory.addVisualizer(svgVisualizer);

View File

@@ -0,0 +1,5 @@
.svgViewer {
svg {
overflow: auto;
}
}

View File

@@ -0,0 +1,256 @@
function sqr(a: number) {
return a * a;
}
export type PointLike =
| Point
| { x?: number; y: number }
| { x: number; y?: number };
export function point(data: PointLike) {
if (data instanceof Point) {
return data;
}
return new Point(data.x || 0, data.y || 0);
}
export class Point {
public static readonly Zero = new Point(0, 0);
constructor(public readonly x: number, public readonly y: number) {}
public mapXY(fn: (arg: number) => number): Point {
return new Point(fn(this.x), fn(this.y));
}
public distance(other: PointLike = Point.Zero): number {
const d = this.sub(other);
return Math.sqrt(sqr(d.x) + sqr(d.y));
}
public sub(other: PointLike): Point {
const o = point(other);
return new Point(this.x - o.x, this.y - o.y);
}
public add(other: PointLike): Point {
const o = point(other);
return new Point(this.x + o.x, this.y + o.y);
}
public mul(scalar: number): Point {
return new Point(this.x * scalar, this.y * scalar);
}
public div(scalar: number): Point {
return new Point(this.x / scalar, this.y / scalar);
}
public equals(other: PointLike) {
const o = point(other);
return this.x === o.x && this.y === o.y;
}
public angle(): number {
const angle = Math.atan2(this.x, this.y);
return angle;
}
public getPointCloserTo(dest: PointLike, dist: number): Point {
if (this.equals(dest)) return this;
const angle = point(dest)
.sub(this)
.angle();
const result = new Point(
this.x + Math.sin(angle) * dist,
this.y + Math.cos(angle) * dist
);
return result;
}
public toJson(): { x: number; y: number } {
return { x: this.x, y: this.y };
}
}
function turn(p1: Point, p2: Point, p3: Point): number {
const A = (p3.y - p1.y) * (p2.x - p1.x);
const B = (p2.y - p1.y) * (p3.x - p1.x);
return A > B + Number.MIN_VALUE ? 1 : A + Number.MIN_VALUE < B ? -1 : 0;
}
export function isIntersect(
aStart: Point,
aEnd: Point,
bStart: Point,
bEnd: Point
): boolean {
return (
turn(aStart, bStart, bEnd) != turn(aEnd, bStart, bEnd) &&
turn(aStart, aEnd, bStart) != turn(aStart, aEnd, bEnd)
);
}
/**
* Liang-Barsky function by Daniel White
*
* @link http://www.skytopia.com/project/articles/compsci/clipping.html
*
* @param {number} x0
* @param {number} y0
* @param {number} x1
* @param {number} y1
* @param {array<number>} bbox
* @return {array<array<number>>|null}
*/
function liangBarsky(
x0: number,
y0: number,
x1: number,
y1: number,
bbox: [number, number, number, number]
): [[number, number], [number, number]] | null {
let [xmin, xmax, ymin, ymax] = bbox;
let t0 = 0,
t1 = 1;
let dx = x1 - x0,
dy = y1 - y0;
let p = 0,
q = 0,
r = 0;
for (let edge: 0 | 1 | 2 | 3 = 0; edge < 4; edge++) {
// Traverse through left, right, bottom, top edges.
if (edge === 0) {
p = -dx;
q = -(xmin - x0);
} else if (edge === 1) {
p = dx;
q = xmax - x0;
} else if (edge === 2) {
p = -dy;
q = -(ymin - y0);
} else if (edge === 3) {
p = dy;
q = ymax - y0;
}
r = q / p;
if (p === 0 && q < 0) return null; // Don't draw line at all. (parallel line outside)
if (p < 0) {
if (r > t1) return null;
// Don't draw line at all.
else if (r > t0) t0 = r; // Line is clipped!
} else if (p > 0) {
if (r < t0) return null;
// Don't draw line at all.
else if (r < t1) t1 = r; // Line is clipped!
}
}
return [[x0 + t0 * dx, y0 + t0 * dy], [x0 + t1 * dx, y0 + t1 * dy]];
}
export function intersectRectWithLine(
start: Point,
end: Point,
rect: Rectangle
): { first: Point; second: Point } | undefined {
const r = liangBarsky(start.x, start.y, end.x, end.y, [
rect.topLeft.x,
rect.bottomRight.x,
rect.topLeft.y,
rect.bottomRight.y,
]);
if (!r) {
return undefined;
}
return {
first: new Point(r[0][0], r[0][1]),
second: new Point(r[1][0], r[1][1]),
};
}
// first zoom then translate
export function scale(
clientOffset: Point,
clientSize: Point,
viewSize: Point
): { clientZoom: number; clientOffset: Point } {
const clientRatio = clientSize.x / clientSize.y;
const viewRatio = viewSize.x / viewSize.y;
let zoom = 1;
if (clientRatio < viewRatio) zoom = viewSize.y / clientSize.y;
else zoom = viewSize.x / clientSize.x;
const clientMid = clientOffset.mul(zoom).add(clientSize.mul(zoom / 2));
const viewMid = viewSize.div(2);
const clientOffset2 = viewMid.sub(clientMid);
return { clientOffset: clientOffset2, clientZoom: zoom };
}
export class Rectangle {
public static ofSize(position: PointLike, size: PointLike): Rectangle {
const pos = point(position);
return new Rectangle(pos, pos.add(size));
}
public static spanning(first: Point, ...rest: Point[]): Rectangle {
let min = first.toJson();
let max = first.toJson();
for (const p of rest) {
min.x = Math.min(min.x, p.x);
min.y = Math.min(min.y, p.y);
max.x = Math.max(max.x, p.x);
max.y = Math.max(max.y, p.y);
}
return new Rectangle(point(min), point(max));
}
constructor(
public readonly topLeft: Point,
public readonly bottomRight: Point
) {}
get center(): Point {
return this.bottomRight.add(this.topLeft).div(2);
}
get size(): Point {
return this.bottomRight.sub(this.topLeft);
}
get topRight(): Point {
return new Point(this.bottomRight.x, this.topLeft.y);
}
get bottomLeft(): Point {
return new Point(this.topLeft.x, this.bottomRight.y);
}
public intersects(selectionRect: Rectangle): boolean {
return !(
selectionRect.topLeft.x > this.bottomRight.x ||
selectionRect.bottomRight.x < this.topLeft.x ||
selectionRect.topLeft.y > this.bottomRight.y ||
selectionRect.bottomRight.y < this.topLeft.y
);
}
equals(other: Rectangle) {
return (
this.topLeft.equals(other.topLeft) &&
this.bottomRight.equals(other.bottomRight)
);
}
}

View File

@@ -0,0 +1,90 @@
import { Point, Rectangle } from "./Point";
import * as React from "react";
import { Properties } from "csstype";
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
export function omit<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
const newObj: Omit<T, K> = {} as any;
for (const [key, val] of Object.entries(obj)) {
if (!keys.includes(key as K)) {
(newObj as any)[key] = val;
}
}
return newObj;
}
export interface SvgAttributes<T> extends React.DOMAttributes<T> {
stroke?: string;
className?: string;
style?: Properties;
pointerEvents?: "none" | "auto" | "initial";
}
export function SvgText(
props: {
position: Point;
children: any;
childRef?: React.Ref<SVGTextElement>;
textAnchor?: "middle" | "end" | "start";
dominantBaseline?: "central" | "middle";
} & SvgAttributes<SVGTextElement>
) {
return (
<text
x={props.position.x}
y={props.position.y}
ref={props.childRef}
{...omit(props, ["position", "childRef"])}
/>
);
}
export function SvgCircle(
props: {
center: Point;
radius: number;
childRef?: React.Ref<SVGCircleElement>;
} & SvgAttributes<SVGCircleElement>
) {
return (
<circle
cx={props.center.x}
cy={props.center.y}
r={props.radius}
ref={props.childRef}
{...omit(props, ["center", "radius", "childRef"])}
/>
);
}
export function SvgLine(
props: { start: Point; end: Point } & SvgAttributes<SVGLineElement>
) {
return (
<line
x1={props.start.x}
y1={props.start.y}
x2={props.end.x}
y2={props.end.y}
{...omit(props, ["end", "start"])}
/>
);
}
export function SvgRect(
props: { rectangle: Rectangle; fill?: string } & SvgAttributes<
SVGRectElement
>
) {
const r = props.rectangle;
return (
<rect
x={r.topLeft.x}
y={r.topLeft.y}
width={r.size.x}
height={r.size.y}
{...omit(props, ["rectangle"])}
/>
);
}

View File

@@ -0,0 +1,545 @@
import { observer, disposeOnUnmount } from "mobx-react";
import { observable, autorun, computed, action, runInAction } from "mobx";
import * as React from "react";
import * as classNames from "classnames";
import { Rectangle, Point } from "./Point";
import { SvgLine } from "./SvgElements";
import { Item } from ".";
// import "./style.scss";
export class TreeViewModel<TData = unknown> {
private started = true;
start() {
this.started = true;
this.updateOffsets();
}
stop() {
this.started = false;
}
private version = 0;
private lastUpdateVersion = -1;
@observable public root: TreeNodeViewModel<TData> | undefined = undefined;
public invalidateOffsets() {
this.version++;
}
@action
public updateOffsets() {
if (!this.started) {
return;
}
if (!this.root) {
return;
}
if (this.lastUpdateVersion === this.version) {
return;
}
this.lastUpdateVersion = this.version;
console.log("update");
this.root.updateOffsets();
}
public get marked(): TreeNodeViewModel<TData>[] {
const result = new Array<TreeNodeViewModel<TData>>();
function recurse(node: TreeNodeViewModel<TData>) {
if (node.isMarked) {
result.push(node);
}
for (const c of node.children) {
recurse(c);
}
}
if (this.root) {
recurse(this.root);
}
return result;
}
@observable selected: TreeNodeViewModel<TData> | undefined = undefined;
@action public select(current: TreeNodeViewModel<TData> | undefined) {
if (this.selected === current) {
return;
}
if (this.selected) {
this.selected.isSelected = false;
}
if (current) {
current.isSelected = true;
}
this.selected = current;
}
@action public toggleSelect(current: TreeNodeViewModel<TData>) {
if (this.selected === current) {
this.select(undefined);
} else {
this.select(current);
}
}
private selectOnHoverEnabled = false;
private selectedBeforeHover:
| TreeNodeViewModel<TData>
| undefined = undefined;
@action public handleHoverEvent(
current: TreeNodeViewModel<TData> | undefined
) {
if (this.selectOnHoverEnabled) {
if (current) {
this.select(current);
} else {
this.select(this.selectedBeforeHover);
}
}
}
setSelectOnHover(enable: boolean) {
if (this.selectOnHoverEnabled === enable) {
return;
}
if (enable) {
this.selectedBeforeHover = this.selected;
} else if (!this.selected) {
this.select(this.selectedBeforeHover);
}
this.selectOnHoverEnabled = enable;
}
}
export class TreeNodeViewModel<TData = unknown> {
public parent: TreeNodeViewModel<TData> | null = null;
constructor(
public readonly treeViewModel: TreeViewModel<TData>,
public readonly children: TreeNodeViewModel<TData>[],
public readonly items: Item[],
public readonly segment: string,
public readonly data: TData
) {}
@observable expanderOffsetRect: Rectangle | undefined = undefined;
private expanderDiv: HTMLDivElement | null = null;
@action.bound
setExpanderDiv(div: HTMLDivElement | null) {
this.expanderDiv = div;
this.treeViewModel.invalidateOffsets();
}
@observable public expanded: boolean = false;
@observable public isMarked: boolean = false;
@observable public isSelected: boolean = false;
@action
public toggleExpanded() {
this.expanded = !this.expanded;
this.treeViewModel.invalidateOffsets();
}
@computed public get isChildOrThisMarked(): boolean {
if (this.isMarked) return true;
return this.children.some(c => c.isChildOrThisMarked);
}
@computed public get isChildOrThisSelected(): boolean {
if (this.isSelected) return true;
return this.children.some(c => c.isChildOrThisSelected);
}
@computed public get isParentOrThisSelected(): boolean {
if (this.isSelected) return true;
if (this.parent) return this.parent.isParentOrThisSelected;
return false;
}
get path(): string[] {
const result = new Array<string>();
let c: TreeNodeViewModel = this;
while (c.parent) {
result.unshift(c.segment);
c = c.parent;
}
if (result.length === 0) {
result.push("root");
}
return result;
}
updateOffsets() {
for (const c of this.children) {
c.updateOffsets();
}
if (!this.expanderDiv) {
return;
}
const newRect = Rectangle.ofSize(
new Point(this.expanderDiv.offsetLeft, this.expanderDiv.offsetTop),
new Point(
this.expanderDiv.offsetWidth,
this.expanderDiv.offsetHeight
)
);
if (
!this.expanderOffsetRect ||
!newRect.equals(this.expanderOffsetRect)
) {
this.expanderOffsetRect = newRect;
}
}
}
const isValidFunctionName = (function() {
var validName = /^[$A-Z_][0-9A-Z_$]*$/i;
var reserved = {
abstract: true,
boolean: true,
// ...
with: true,
} as any;
return function(s: string) {
// Ensure a valid name and not reserved.
return validName.test(s) && !reserved[s];
};
})();
@observer
export class TreeWithPathView extends React.Component<{
model: TreeViewModel;
}> {
render() {
const model = this.props.model;
return (
<div className="component-TreeWithPathView">
<div className="part-path">
<span>
{model.selected ? (
<span>
{model.selected.path.reduce((acc, v) => {
acc = acc.slice();
function add() {
acc.push(
<span
className="part-path-item"
key={acc.length}
>
{v}
</span>
);
}
if (isValidFunctionName(v)) {
if (acc.length > 0) {
acc.push(
<span key={acc.length}>.</span>
);
}
add();
} else {
acc.push(
<span key={acc.length}>[</span>
);
add();
acc.push(
<span key={acc.length}>]</span>
);
}
return acc;
}, new Array<React.ReactElement>())}
</span>
) : (
"(Nothing Selected)"
)}
</span>
</div>
<div className="part-tree">
<TreeView model={model} />
</div>
</div>
);
}
}
@observer
export class TreeView extends React.Component<{ model: TreeViewModel }> {
componentWillUpdate(newProps: this["props"]) {
newProps.model.stop();
}
componentWillMount() {
this.props.model.stop();
}
componentDidUpdate() {
this.props.model.start();
}
componentDidMount() {
this.props.model.start();
}
render() {
const model = this.props.model;
return (
<div
tabIndex={0}
className="component-TreeView"
onKeyDown={e => {
if (e.keyCode === 18) {
// alt
e.preventDefault();
e.stopPropagation();
model.setSelectOnHover(true);
}
}}
onKeyUp={e => {
if (e.keyCode === 18) {
// alt
e.preventDefault();
e.stopPropagation();
model.setSelectOnHover(false);
}
}}
>
{model.root && (
<>
<svg className="part-svg">
<TreeNodeSvgView model={model.root} />
</svg>
<div className="part-node">
<TreeNodeView model={model.root} />
</div>
</>
)}
</div>
);
}
}
@observer
export class TreeNodeSvgView extends React.Component<{
model: TreeNodeViewModel;
}> {
render() {
const model = this.props.model;
if (
!model.expanderOffsetRect ||
model.children.length === 0 ||
model.expanded
) {
return <g />;
}
function findLastExpanderOffset(
predicate: (v: TreeNodeViewModel) => boolean
): Rectangle | undefined {
const all = model.children
.filter(predicate)
.map(c => c.expanderOffsetRect);
const last = all[all.length - 1];
return last;
}
const lastOffset = findLastExpanderOffset(() => true);
const selectedOffset = findLastExpanderOffset(
v => v.isChildOrThisSelected
);
const markedOffset = findLastExpanderOffset(v => v.isChildOrThisMarked);
if (!lastOffset) {
return <g />;
}
const start = model.expanderOffsetRect.center.add({ y: 10 });
return (
<g className="component-TreeNodeSvgView">
{markedOffset && (
<SvgLine
className="path childOrThisMarked"
start={start}
end={new Point(start.x, markedOffset.center.y)}
/>
)}
{selectedOffset && (
<SvgLine
className="path childOrThisSelected"
start={start}
end={new Point(start.x, selectedOffset.center.y)}
/>
)}
<SvgLine
className="path"
start={start}
end={new Point(start.x, lastOffset.center.y)}
/>
{model.children.map((c, idx) => {
const end = c.expanderOffsetRect!.center.sub({ x: 5 });
return (
<g key={idx}>
{c.isChildOrThisMarked && (
<SvgLine
className={classNames(
"path",
"childOrThisMarked"
)}
start={new Point(start.x, end.y)}
end={end}
/>
)}
<SvgLine
className={classNames(
"path",
c.isChildOrThisSelected &&
"childOrThisSelected"
)}
start={new Point(start.x, end.y)}
end={end}
/>
</g>
);
})}
{model.children.map((c, idx) => (
<TreeNodeSvgView key={idx} model={c} />
))}
</g>
);
}
}
@observer
export class TreeNodeView extends React.Component<{
model: TreeNodeViewModel;
}> {
@action.bound
private clickHandler() {
this.props.model.treeViewModel.toggleSelect(this.props.model);
}
private handleMouseEnterOrLeave(enter: boolean) {
this.props.model.treeViewModel.handleHoverEvent(
enter ? this.props.model : undefined
);
}
@observable
private rootDiv: HTMLElement | null = null;
@action.bound
private setRootDiv(div: HTMLElement | null) {
this.rootDiv = div;
}
@disposeOnUnmount
private readonly _autorun = autorun(() => {
if (this.props.model.isMarked && this.rootDiv) {
this.rootDiv.scrollIntoView({ block: "nearest" });
}
});
public componentDidMount() {
this.props.model.treeViewModel.updateOffsets();
}
public componentDidUpdate() {
this.props.model.treeViewModel.updateOffsets();
}
public render(): JSX.Element {
const model = this.props.model;
const collapsed = model.expanded;
console.log("render");
return (
<div
className={classNames(
"component-TreeNodeView",
model.expanded && "collapsed",
model.isMarked && "selected",
model.isSelected && "hovered",
model.isChildOrThisSelected &&
!model.isSelected &&
"childHovered",
model.isParentOrThisSelected &&
!model.isSelected &&
"parentHovered"
)}
ref={this.setRootDiv}
>
<div
className="part-header"
onMouseOver={e => {
this.handleMouseEnterOrLeave(true);
e.stopPropagation();
}}
onMouseOut={e => {
this.handleMouseEnterOrLeave(false);
e.stopPropagation();
}}
>
<div
ref={model.setExpanderDiv}
onClick={() => model.toggleExpanded()}
className={classNames(
"part-collapser",
model.children.length > 0 &&
"decorator-collapse-icon",
!collapsed && "expanded"
)}
/>
<span className="part-text" onClick={this.clickHandler}>
{renderItems(model.items)}
</span>
</div>
{model.children.length > 0 && !model.expanded && (
<div className="part-children">
{model.children.map((c, idx) => (
<TreeNodeView model={c} key={idx} />
))}
</div>
)}
</div>
);
}
}
function renderItems(items: Item[]): React.ReactNode {
return items.map((i, idx) => {
switch (i.emphasis) {
case "style1":
return (
<span key={idx} className="style1">
{i.text}
</span>
);
case "style2":
return (
<span key={idx} className="style2">
{i.text}
</span>
);
case "style3":
return (
<span key={idx} className="style3">
{i.text}
</span>
);
default:
return (
<span key={idx} className="unstyled">
{i.text}
</span>
);
}
});
}

View File

@@ -0,0 +1,121 @@
import {
TreeViewModel,
TreeNodeViewModel,
TreeView,
TreeWithPathView,
} from "./Views";
import * as React from "react";
import {
sOpenObject,
sLiteral,
sLazy,
sString,
sBoolean,
sArrayOf,
Serializer,
sOptionalProp,
sUnion,
} from "@hediet/semantic-json";
import {
createVisualizer,
ReactVisualization,
globalVisualizationFactory,
} from "@hediet/visualization-core";
import { visualizationNs } from "../../consts";
type TreeNode<T = {}> = {
children: TreeNode<T>[];
items: Item[];
segment?: string; // like "root", ".name" or "[0]"
isMarked?: boolean;
} & T;
export interface Item {
text: string;
emphasis?: "style1" | "style2" | "style3" | string;
}
const sTreeNode: Serializer<TreeNode> = sLazy(() =>
sOpenObject({
children: sArrayOf(sTreeNode),
items: sArrayOf(
sOpenObject({
text: sString(),
emphasis: sOptionalProp(
sUnion(
[
sLiteral("style1"),
sLiteral("style2"),
sLiteral("style3"),
sString(),
],
{ inclusive: true }
)
),
}).defineAs(visualizationNs("TreeNodeItem"))
),
segment: sOptionalProp(sString()),
isMarked: sOptionalProp(sBoolean()),
}).defineAs(visualizationNs("TreeNode"))
);
const sTree = sOpenObject({
kind: sOpenObject({
tree: sLiteral(true),
}),
root: sTreeNode,
});
export const treeVisualizer = createVisualizer({
id: "tree",
name: "Tree",
serializer: sTree,
getVisualization: (data, self) =>
new ReactVisualization(self, { priority: 1200 }, () => {
const m = createTreeViewModelFromTreeNodeData(
data.root,
() => undefined
);
return <TreeWithPathView model={m} />;
}),
});
globalVisualizationFactory.addVisualizer(treeVisualizer);
export function createTreeViewModelFromTreeNodeData<TData, TNodeData>(
root: TreeNode<TNodeData>,
dataSelector: (node: TreeNode<TNodeData>) => TData
): TreeViewModel<TData> {
const m = new TreeViewModel<TData>();
m.root = recurse(root, m);
return m;
function recurse(
node: TreeNode<TNodeData>,
viewModel: TreeViewModel<TData>
): TreeNodeViewModel<TData> {
const children: TreeNodeViewModel<TData>[] = node.children.map(c =>
recurse(c, viewModel)
);
let segment = node.segment;
if (!segment && node.items.length > 0) {
segment = node.items[0].text;
} else {
segment = "";
}
const model = new TreeNodeViewModel<TData>(
viewModel,
children,
node.items,
segment,
dataSelector(node)
);
model.isMarked = !!node.isMarked;
for (const c of children) {
c.parent = model;
}
return model;
}
}

View File

@@ -0,0 +1,162 @@
.theme-dark {
--visualizer-tree-childOrThisMarked-background: orange;
--visualizer-tree-childOrThisSelected-background: white;
--visualizer-tree-pathColor: gray;
--visualizer-tree-style1-foreground: #5594ea;
--visualizer-tree-style2-foreground: #c88544; //#38b968;
--visualizer-tree-style3-foreground: darkred;
--visualizer-tree-style3-border: black;
--visualizer-tree-unstyled-foreground: lightgray;
--visualizer-tree-path-foreground: white;
}
.theme-light {
--visualizer-tree-childOrThisMarked-background: orange;
--visualizer-tree-childOrThisSelected-background: black;
--visualizer-tree-pathColor: gray;
--visualizer-tree-style1-foreground: blue;
--visualizer-tree-style2-foreground: darkgreen;
--visualizer-tree-style3-foreground: darkred;
--visualizer-tree-style3-border: black;
--visualizer-tree-unstyled-foreground: black;
--visualizer-tree-path-foreground: black;
}
.component-TreeView {
overflow: auto;
scroll-behavior: smooth;
height: 100%;
position: relative;
display: grid;
& > .part-svg,
& > .part-node {
height: 100%;
width: 100%;
grid-column: 1;
grid-row: 1;
}
}
.component-TreeNodeSvgView {
.path {
stroke: var(--visualizer-tree-pathColor);
stroke-dasharray: 1;
&.childOrThisMarked {
stroke: var(--visualizer-tree-childOrThisMarked-background);
stroke-width: 5;
stroke-dasharray: 0;
stroke-linecap: round;
}
&.childOrThisSelected {
stroke: var(--visualizer-tree-childOrThisSelected-background);
stroke-width: 2;
stroke-dasharray: 0;
}
}
}
.component-TreeNodeView {
font-family: -apple-system, BlinkMacSystemFont, Segoe WPC, Segoe UI,
HelveticaNeue-Light, Ubuntu, Droid Sans, sans-serif;
.part-header {
padding: 2px;
padding-left: 5px;
margin: 0;
display: flex;
align-items: center;
.part-collapser {
width: 10px;
height: 10px;
margin-right: 6px;
}
.part-text {
cursor: pointer;
span.style1 {
color: var(--visualizer-tree-style1-foreground);
}
span.style2 {
color: var(--visualizer-tree-style2-foreground);
text-decoration: underline;
padding: 1px 3px;
margin-left: 5px;
}
span.style3 {
color: var(--visualizer-tree-style3-foreground);
border: var(--visualizer-tree-style3-border) solid 1px;
padding: 1px 3px;
margin-left: 5px;
}
span.unstyled {
color: var(--visualizer-tree-unstyled-foreground);
}
}
}
.part-children {
margin-left: 0px;
margin-top: 0px;
padding: 5px;
padding-left: 15px;
}
&.selected {
& > .part-header {
font-weight: bold;
}
}
&.hovered {
& > .part-header > span {
font-weight: bold;
}
& > .part-children {
background: transparentize(gray, 0.8);
}
}
}
.component-TreeWithPathView {
display: flex;
flex-direction: column;
height: 100%;
.part-path {
overflow: hidden;
white-space: pre;
padding: 5px 10px;
border-bottom: solid gray 1px;
color: lightgray;
.part-path-item {
color: var(--visualizer-tree-path-foreground);
margin-left: 1px;
margin-right: 1px;
}
}
.part-tree {
overflow: auto;
flex: 1;
}
}
.decorator-collapse-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cpath fill='%23646465' d='M6 4v8l4-4-4-4zm1 2.414L8.586 8 7 9.586V6.414z'/%3E%3C/svg%3E");
background-position: 50%;
background-position-x: 50%;
background-position-y: center;
background-repeat: no-repeat;
&.expanded {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Cpath fill='%23646465' d='M11 10.07H5.344L11 4.414v5.656z'/%3E%3C/svg%3E");
}
}