1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2025-12-06 10:08:52 +08:00
This commit is contained in:
lana-k
2025-06-01 19:16:26 +02:00
parent 3ee825defe
commit 1601514cca
12 changed files with 19115 additions and 25 deletions

17598
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,9 @@
"codemirror-editor-vue3": "^2.8.0",
"core-js": "^3.6.5",
"dataurl-to-blob": "^0.0.1",
"graphology": "^0.26.0",
"graphology-layout": "^0.6.1",
"graphology-layout-forceatlas2": "^0.10.1",
"html2canvas": "^1.1.4",
"jquery": "^3.6.0",
"nanoid": "^3.1.12",
@@ -28,6 +31,8 @@
"react": "^16.14.0",
"react-chart-editor": "^0.46.1",
"react-dom": "^16.14.0",
"seedrandom": "^3.0.5",
"sigma": "^3.0.1",
"sql.js": "file:./lib/sql-js",
"tiny-emitter": "^2.1.0",
"veaury": "^2.5.1",

View File

@@ -0,0 +1,77 @@
<template>
<Field
label="Hierarchy attributes"
fieldContainerClassName="multiselect-field"
>
<multiselect
:modelValue="modelValue.hierarchyAttributes"
class="sqliteviz-select"
:options="keyOptions"
:multiple="true"
:hideSelected="true"
:closeOnSelect="true"
:showLabels="false"
:max="keyOptions.length"
placeholder=""
openDirection="bottom"
@update:model-value="update('hierarchyAttributes', $event)"
>
<template #maxElements>
<span class="no-results">No Results</span>
</template>
<template #placeholder>Select an Option</template>
<template #noResult>
<span class="no-results">No Results</span>
</template>
</multiselect>
</Field>
<Field label="Seed value">
<NumericInput
:value="modelValue.seedValue"
@update="update('seedValue', $event)"
/>
</Field>
</template>
<script>
import { applyPureReactInVue } from 'veaury'
import Field from 'react-chart-editor/lib/components/fields/Field'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import Multiselect from 'vue-multiselect'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Field: applyPureReactInVue(Field),
NumericInput: applyPureReactInVue(NumericInput),
Dropdown: applyPureReactInVue(Dropdown),
Multiselect
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
methods: {
update(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>
<style scoped>
:deep(.sqliteviz-select.multiselect--active .multiselect__input) {
width: 100% !important;
}
:deep(.multiselect-field .field__widget > *) {
flex-grow: 1 !important;
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<Field label="Adjust sizes">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.adjustSizes"
@option-change="update('adjustSizes', $event)"
/>
</Field>
<Field label="Barnes-Hut optimize">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.barnesHutOptimize"
@option-change="update('barnesHutOptimize', $event)"
/>
</Field>
<Field v-show="modelValue.barnesHutOptimize" label="Barnes-Hut Theta">
<NumericInput
:value="modelValue.barnesHutTheta"
@update="update('barnesHutTheta', $event)"
/>
</Field>
<Field label="Gravity">
<NumericInput
:value="modelValue.gravity"
@update="update('gravity', $event)"
/>
</Field>
<Field label="Strong gravity mode">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.strongGravityMode"
@option-change="update('strongGravityMode', $event)"
/>
</Field>
<Field label="Noack's LinLog model">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.linLogMode"
@option-change="update('linLogMode', $event)"
/>
</Field>
<Field label="Out bound attraction distribution">
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.outboundAttractionDistribution"
@option-change="update('outboundAttractionDistribution', $event)"
/>
</Field>
<Field label="Scaling ratio">
<NumericInput
:value="modelValue.scalingRatio"
@update="update('scalingRatio', $event)"
/>
</Field>
<Field label="Slow down">
<NumericInput
:value="modelValue.slowDown"
:min="1"
@update="update('slowDown', $event)"
/>
</Field>
<Field label="Edge weight influence">
<NumericInput
:value="modelValue.edgeWeightInfluence"
@update="update('edgeWeightInfluence', $event)"
/>
</Field>
<Field label="Edge weight">
<Dropdown
:options="keyOptions"
:value="modelValue.weightSource"
@change="update('weightSource', $event)"
/>
</Field>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import Field from 'react-chart-editor/lib/components/fields/Field'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Field: applyPureReactInVue(Field),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
booleanOptions: markRaw([
{ label: 'Yes', value: true },
{ label: 'No', value: false }
])
}
},
methods: {
update(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<Field label="Seed value">
<NumericInput
:value="modelValue.seedValue"
@update="update('seedValue', $event)"
/>
</Field>
</template>
<script>
import { applyPureReactInVue } from 'veaury'
import Field from 'react-chart-editor/lib/components/fields/Field'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Field: applyPureReactInVue(Field),
NumericInput: applyPureReactInVue(NumericInput)
},
props: {
modelValue: Object
},
emits: ['update:modelValue'],
methods: {
update(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

@@ -0,0 +1,40 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 4C5 5.10457 4.10457 6 3 6C1.89543 6 1 5.10457 1 4C1 2.89543 1.89543 2 3 2C4.10457 2 5 2.89543 5 4Z"
fill="#A2B1C6"
/>
<path
d="M17 7.5C17 8.88071 15.8807 10 14.5 10C13.1193 10 12 8.88071 12 7.5C12 6.11929 13.1193 5 14.5 5C15.8807 5 17 6.11929 17 7.5Z"
fill="#A2B1C6"
/>
<path
d="M8 13.5C8 14.8807 6.88071 16 5.5 16C4.11929 16 3 14.8807 3 13.5C3 12.1193 4.11929 11 5.5 11C6.88071 11 8 12.1193 8 13.5Z"
fill="#A2B1C6"
/>
<path
d="M2.93128 5.31436L3.90527 5.08778L5.48693 11.8867L4.51294 12.1133L2.93128 5.31436Z"
fill="#A2B1C6"
/>
<path
d="M12.9447 7.79159L13.5548 8.58392L7.30516 13.3962L6.69507 12.6038L12.9447 7.79159Z"
fill="#A2B1C6"
/>
<path
d="M14.1316 6.51712L3.13166 3.51723L2.86844 4.48202L13.8684 7.48191L14.1316 6.51712Z"
fill="#A2B1C6"
/>
</svg>
</template>
<script>
export default {
name: 'GraphIcon'
}
</script>

View File

@@ -0,0 +1,20 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4C3 3.44772 3.44772 3 4 3H14C14.5523 3 15 3.44772 15 4V14C15 14.5523 14.5523 15 14 15H4C3.44772 15 3 14.5523 3 14V4Z"
fill="#A2B1C6"
/>
</svg>
</template>
<script>
export default {
name: 'StopIcon'
}
</script>

View File

@@ -0,0 +1,52 @@
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { localizeString } from 'react-chart-editor/lib'
class EditorControls extends Component {
constructor(props, context) {
super(props, context)
this.localize = key =>
localizeString(this.props.dictionaries || {}, this.props.locale, key)
}
getChildContext() {
return {
dictionaries: this.props.dictionaries || {},
localize: this.localize,
locale: this.props.locale
}
}
render() {
return (
<div
className={
'editor_controls plotly-editor--theme-provider' +
`${this.props.className ? ` ${this.props.className}` : ''}`
}
>
{this.props.children}
</div>
)
}
}
EditorControls.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
dictionaries: PropTypes.object,
locale: PropTypes.string
}
EditorControls.defaultProps = {
locale: 'en'
}
EditorControls.childContextTypes = {
dictionaries: PropTypes.object,
locale: PropTypes.string,
localize: PropTypes.func
}
export default EditorControls

364
src/lib/graphHelper.js Normal file
View File

@@ -0,0 +1,364 @@
import { nanoid } from 'nanoid'
import { COLOR_PICKER_CONSTANTS } from 'react-colorscales'
import tinycolor from 'tinycolor2'
const TYPE_NODE = 0
const TYPE_EDGE = 1
const DEFAULT_SCALE = COLOR_PICKER_CONSTANTS.DEFAULT_SCALE
export function buildNodes(graph, dataSources, options) {
const docColumn = Object.keys(dataSources)[0] || 'doc'
const { objectType, nodeId } = options.structure
if (objectType && nodeId) {
const nodes = dataSources[docColumn]
.map(json => JSON.parse(json))
.filter(item => item[objectType] === TYPE_NODE)
nodes.forEach(node => {
graph.addNode(node[nodeId], {
data: node
})
})
}
}
export function buildEdges(graph, dataSources, options) {
const docColumn = Object.keys(dataSources)[0] || 'doc'
const { objectType, edgeSource, edgeTarget } = options.structure
if (objectType && edgeSource && edgeTarget) {
const edges = dataSources[docColumn]
.map(json => JSON.parse(json))
.filter(item => item[objectType] === TYPE_EDGE)
edges.forEach(edge => {
const source = edge[edgeSource]
const target = edge[edgeTarget]
if (graph.hasNode(source) && graph.hasNode(target)) {
graph.addEdge(source, target, {
data: edge
})
}
})
}
}
export function updateNodes(graph, attributeUpdates) {
const changeMethods = []
if (attributeUpdates.label) {
changeMethods.push(getUpdateLabelMethod(attributeUpdates.label))
}
if (attributeUpdates.size) {
changeMethods.push(getUpdateSizeMethod(graph, attributeUpdates.size))
}
if (attributeUpdates.color) {
changeMethods.push(getUpdateNodeColorMethod(graph, attributeUpdates.color))
}
graph.forEachNode(nodeId => {
graph.updateNode(nodeId, attributes => {
const newAttributes = { ...attributes }
changeMethods.forEach(method => method(newAttributes, nodeId))
return newAttributes
})
})
}
export function updateEdges(graph, attributeUpdates) {
const changeMethods = []
if (attributeUpdates.label) {
changeMethods.push(getUpdateLabelMethod(attributeUpdates.label))
}
if (attributeUpdates.size) {
changeMethods.push(getUpdateSizeMethod(graph, attributeUpdates.size))
}
if (attributeUpdates.color) {
changeMethods.push(getUpdateEdgeColorMethod(graph, attributeUpdates.color))
}
if ('showDirection' in attributeUpdates) {
changeMethods.push(
attributes =>
(attributes.type = attributeUpdates.showDirection ? 'arrow' : 'line')
)
}
graph.forEachEdge((edgeId, attributes, source, target) => {
graph.updateEdge(source, target, attributes => {
const newAttributes = { ...attributes }
changeMethods.forEach(method => method(newAttributes, edgeId))
return newAttributes
})
})
}
function getUpdateLabelMethod(labelSettings) {
const { source } = labelSettings
return attributes => {
const label = attributes.data[source] ?? ''
attributes.label = label.toString()
}
}
function getUpdateSizeMethod(graph, sizeSettings) {
const { type, value, source, scale, mode, min, method } = sizeSettings
if (type === 'constant') {
return attributes => (attributes.size = value)
} else if (type === 'variable') {
return getVariabledSizeMethod(mode, source, scale, min)
} else {
return (attributes, nodeId) =>
(attributes.size = Math.max(graph[method](nodeId) * scale, min))
}
}
function getDirectVariableColorUpdateMethod(source) {
return attributes =>
(attributes.color = tinycolor(attributes.data[source]).toHexString())
}
function getUpdateNodeColorMethod(graph, colorSettings) {
const {
type,
value,
source,
sourceUsage,
colorscale,
colorscaleDirection,
mode,
method
} = colorSettings
if (type === 'constant') {
return attributes => (attributes.color = value)
} else if (type === 'variable') {
return sourceUsage === 'map_to'
? getColorMethod(
graph,
mode,
(nodeId, attributes) => attributes.data[source],
colorscale,
colorscaleDirection,
getNodeValueScale
)
: getDirectVariableColorUpdateMethod(source)
} else {
return getColorMethod(
graph,
mode,
nodeId => graph[method](nodeId),
colorscale,
colorscaleDirection,
getNodeValueScale
)
}
}
function getUpdateEdgeColorMethod(graph, colorSettings) {
const {
type,
value,
source,
sourceUsage,
colorscale,
colorscaleDirection,
mode
} = colorSettings
if (type === 'constant') {
return attributes => (attributes.color = value)
} else {
return sourceUsage === 'map_to'
? getColorMethod(
graph,
mode,
(edgeId, attributes) => attributes.data[source],
colorscale,
colorscaleDirection,
getEdgeValueScale
)
: getDirectVariableColorUpdateMethod(source)
}
}
function getVariabledSizeMethod(mode, source, scale, min) {
if (mode === 'diameter') {
return attributes =>
(attributes.size = Math.max(
(attributes.data[source] / 2) * scale,
min / 2
))
} else if (mode === 'area') {
return attributes =>
(attributes.size = Math.max(
Math.sqrt((attributes.data[source] / 2) * scale),
min / 2
))
} else {
return attributes =>
(attributes.size = Math.max(attributes.data[source] * scale, min))
}
}
function getColorMethod(
graph,
mode,
sourceGetter,
selectedColorscale,
colorscaleDirection,
valueScaleGetter
) {
const valueScale = valueScaleGetter(graph, sourceGetter)
let colorscale = selectedColorscale || DEFAULT_SCALE
if (colorscaleDirection === 'reversed') {
colorscale = [...colorscale].reverse()
}
if (mode === 'categorical') {
const colorMap = Object.fromEntries(
valueScale.map((value, index) => [
value,
colorscale[index % colorscale.length]
])
)
return (attributes, nodeId) => {
const category = sourceGetter(nodeId, attributes)
attributes.color = colorMap[category]
}
} else {
const min = valueScale[0]
const max = valueScale[valueScale.length - 1]
const normalizedColorscale = colorscale.map((color, index) => [
index / (colorscale.length - 1),
tinycolor(color).toRgb()
])
return (attributes, nodeId) => {
const value = sourceGetter(nodeId, attributes)
const normalizedValue = (value - min) / (max - min)
if (isNaN(normalizedValue)) {
return
}
const exactMatch = normalizedColorscale.find(
([value]) => value === normalizedValue
)
if (exactMatch) {
attributes.color = tinycolor(exactMatch[1]).toHexString()
return
}
const rightColorIndex = normalizedColorscale.findIndex(
([value]) => value >= normalizedValue
)
const leftColorIndex = (rightColorIndex || 1) - 1
const right = normalizedColorscale[rightColorIndex]
const left = normalizedColorscale[leftColorIndex]
const interpolationFactor =
(normalizedValue - left[0]) / (right[0] - left[0])
const r0 = left[1].r
const g0 = left[1].g
const b0 = left[1].b
const r1 = right[1].r
const g1 = right[1].g
const b1 = right[1].b
attributes.color = tinycolor({
r: r0 + interpolationFactor * (r1 - r0),
g: g0 + interpolationFactor * (g1 - g0),
b: b0 + interpolationFactor * (b1 - b0)
}).toHexString()
}
}
}
function getNodeValueScale(graph, sourceGetter) {
const scaleSet = graph.reduceNodes((res, nodeId, attributes) => {
res.add(sourceGetter(nodeId, attributes))
return res
}, new Set())
return Array.from(scaleSet).sort((a, b) => a - b)
}
function getEdgeValueScale(graph, sourceGetter) {
const scaleSet = graph.reduceEdges((res, edgeId, attributes) => {
res.add(sourceGetter(edgeId, attributes))
return res
}, new Set())
return Array.from(scaleSet).sort((a, b) => a - b)
}
export function getOptionsFromDataSources(dataSources) {
if (!dataSources) {
return []
}
return Object.keys(dataSources).map(name => ({
value: name,
label: name
}))
}
export function getOptionsForSave(state, dataSources) {
// we don't need to save the data, only settings
// so we modify state.data using dereference
const stateCopy = JSON.parse(JSON.stringify(state))
const emptySources = {}
for (const key in dataSources) {
emptySources[key] = []
}
dereference.default(stateCopy.data, emptySources)
return stateCopy
}
export async function getImageDataUrl(element, type) {
const chartElement = element.querySelector('.js-plotly-plot')
return await plotly.toImage(chartElement, {
format: type,
width: null,
height: null
})
}
export function getChartData(element) {
const chartElement = element.querySelector('.js-plotly-plot')
return {
data: chartElement.data,
layout: chartElement.layout
}
}
export function getHtml(options) {
const chartId = nanoid()
return `
<script src="https://cdn.plot.ly/plotly-latest.js" charset="UTF-8"></script>
<div id="${chartId}"></div>
<script>
const el = document.getElementById("${chartId}")
let timeout
function debounceResize() {
clearTimeout(timeout)
timeout = setTimeout(() => {
var r = el.getBoundingClientRect()
Plotly.relayout(el, {width: r.width, height: r.height})
}, 200)
}
const resizeObserver = new ResizeObserver(debounceResize)
resizeObserver.observe(el)
Plotly.newPlot(el, ${JSON.stringify(options.data)}, ${JSON.stringify(options.layout)})
</script>
`
}
export default {
getOptionsFromDataSources,
getOptionsForSave,
getImageDataUrl,
getHtml,
getChartData
}

View File

@@ -0,0 +1,734 @@
<template>
<div class="plotly_editor">
<GraphEditorControls>
<PanelMenuWrapper>
<Panel group="Structure" name="Graph">
<Fold name="Graph">
<Field>Choose keys explanation...</Field>
<Field label="Object type">
<Dropdown
:options="keysOptions"
:value="settings.structure.objectType"
@change="updateStructure('objectType', $event)"
/>
<Field>0 - node; 1 - edge</Field>
</Field>
<Field label="Node Id">
<Dropdown
:options="keysOptions"
:value="settings.structure.nodeId"
@change="updateStructure('nodeId', $event)"
/>
</Field>
<Field label="Edge source">
<Dropdown
:options="keysOptions"
:value="settings.structure.edgeSource"
@change="updateStructure('edgeSource', $event)"
/>
</Field>
<Field label="Edge target">
<Dropdown
:options="keysOptions"
:value="settings.structure.edgeTarget"
@change="updateStructure('edgeTarget', $event)"
/>
</Field>
</Fold>
</Panel>
<Panel group="Style" name="Nodes">
<Fold name="Nodes">
<Field label="Label">
<Dropdown
:options="keysOptions"
:value="settings.style.nodes.label.source"
@change="updateNodes('label.source', $event)"
/>
</Field>
<Field label="Size">
<RadioBlocks
:options="nodeSizeTypeOptions"
:activeOption="settings.style.nodes.size.type"
@option-change="updateNodes('size.type', $event)"
/>
<Field>
<NumericInput
v-if="settings.style.nodes.size.type === 'constant'"
:value="settings.style.nodes.size.value"
:min="1"
@update="updateNodes('size.value', $event)"
/>
<Dropdown
v-if="settings.style.nodes.size.type === 'variable'"
:options="keysOptions"
:value="settings.style.nodes.size.source"
@change="updateNodes('size.source', $event)"
/>
<Dropdown
v-if="settings.style.nodes.size.type === 'calculated'"
:options="nodeCalculatedSizeMethodOptions"
:value="settings.style.nodes.size.method"
@change="updateNodes('size.method', $event)"
/>
</Field>
</Field>
<template v-if="settings.style.nodes.size.type !== 'constant'">
<Field label="Size scale">
<NumericInput
:value="settings.style.nodes.size.scale"
@update="updateNodes('size.scale', $event)"
/>
</Field>
<Field label="Size mode">
<RadioBlocks
:options="nodeSizeModeOptions"
:activeOption="settings.style.nodes.size.mode"
@option-change="updateNodes('size.mode', $event)"
/>
</Field>
<Field label="Minimum size">
<NumericInput
:value="settings.style.nodes.size.min"
@update="updateNodes('size.min', $event)"
/>
</Field>
</template>
<Field label="Color">
<RadioBlocks
:options="nodeColorTypeOptions"
:activeOption="settings.style.nodes.color.type"
@option-change="updateNodes('color.type', $event)"
/>
<Field v-if="settings.style.nodes.color.type === 'constant'">
<ColorPicker
:selectedColor="settings.style.nodes.color.value"
@color-change="updateNodes('color.value', $event)"
/>
</Field>
<template v-else>
<Field>
<Dropdown
v-if="settings.style.nodes.color.type === 'variable'"
:options="keysOptions"
:value="settings.style.nodes.color.source"
@change="updateNodes('color.source', $event)"
/>
<Dropdown
v-if="settings.style.nodes.color.type === 'calculated'"
:options="nodeCalculatedColorMethodOptions"
:value="settings.style.nodes.color.method"
@change="updateNodes('color.method', $event)"
/>
</Field>
<Field>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="settings.style.nodes.color.sourceUsage"
@option-change="updateNodes('color.sourceUsage', $event)"
/>
</Field>
<Field
v-if="settings.style.nodes.color.sourceUsage === 'map_to'"
>
<ColorscalePicker
:selected="settings.style.nodes.color.colorscale"
className="colorscale-picker"
@colorscale-change="updateNodes('color.colorscale', $event)"
/>
</Field>
</template>
</Field>
<Field
v-if="settings.style.nodes.color.type !== 'constant'"
label="Color as"
>
<RadioBlocks
:options="сolorAsOptions"
:activeOption="settings.style.nodes.color.mode"
@option-change="updateNodes('color.mode', $event)"
/>
</Field>
<Field
v-if="settings.style.nodes.color.type !== 'constant'"
label="Colorscale direction"
>
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="settings.style.nodes.color.colorscaleDirection"
@option-change="
updateNodes('color.colorscaleDirection', $event)
"
/>
</Field>
</Fold>
</Panel>
<Panel group="Style" name="Edges">
<Fold name="Edges">
<Field label="Direction">
<RadioBlocks
:options="visibilityOptions"
:activeOption="settings.style.edges.showDirection"
@option-change="updateEdges('showDirection', $event)"
/>
</Field>
<Field label="Label">
<Dropdown
:options="keysOptions"
:value="settings.style.edges.label.source"
@change="updateEdges('label.source', $event)"
/>
</Field>
<Field label="Size">
<RadioBlocks
:options="edgeSizeTypeOptions"
:activeOption="settings.style.edges.size.type"
@option-change="updateEdges('size.type', $event)"
/>
<Field>
<NumericInput
v-if="settings.style.edges.size.type === 'constant'"
:value="settings.style.edges.size.value"
:min="1"
@update="updateEdges('size.value', $event)"
/>
<Dropdown
v-if="settings.style.edges.size.type === 'variable'"
:options="keysOptions"
:value="settings.style.edges.size.source"
@change="updateEdges('size.source', $event)"
/>
</Field>
</Field>
<template v-if="settings.style.edges.size.type !== 'constant'">
<Field label="Size scale">
<NumericInput
:value="settings.style.edges.size.scale"
@update="updateEdges('size.scale', $event)"
/>
</Field>
<Field label="Minimum size">
<NumericInput
:value="settings.style.edges.size.min"
@update="updateEdges('size.min', $event)"
/>
</Field>
</template>
<Field label="Color">
<RadioBlocks
:options="edgeColorTypeOptions"
:activeOption="settings.style.edges.color.type"
@option-change="updateEdges('color.type', $event)"
/>
<Field v-if="settings.style.edges.color.type === 'constant'">
<ColorPicker
:selectedColor="settings.style.edges.color.value"
@color-change="updateEdges('color.value', $event)"
/>
</Field>
<template v-else>
<Field>
<Dropdown
v-if="settings.style.edges.color.type === 'variable'"
:options="keysOptions"
:value="settings.style.edges.color.source"
@change="updateEdges('color.source', $event)"
/>
</Field>
<Field>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="settings.style.edges.color.sourceUsage"
@option-change="updateEdges('color.sourceUsage', $event)"
/>
</Field>
<Field
v-if="settings.style.edges.color.sourceUsage === 'map_to'"
>
<ColorscalePicker
:selected="settings.style.edges.color.colorscale"
className="colorscale-picker"
@colorscale-change="updateEdges('color.colorscale', $event)"
/>
</Field>
</template>
</Field>
<Field
v-if="settings.style.edges.color.type !== 'constant'"
label="Color as"
>
<RadioBlocks
:options="сolorAsOptions"
:activeOption="settings.style.edges.color.mode"
@option-change="updateEdges('color.mode', $event)"
/>
</Field>
<Field
v-if="settings.style.edges.color.type !== 'constant'"
label="Colorscale direction"
>
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="settings.style.edges.color.colorscaleDirection"
@option-change="
updateEdges('color.colorscaleDirection', $event)
"
/>
</Field>
</Fold>
</Panel>
<Panel group="Style" name="Layout">
<Fold name="Layout">
<Field label="Algorithm">
<Dropdown
:options="layoutOptions"
:value="settings.layout.type"
@change="updateLayout($event)"
/>
</Field>
<component
:is="layoutSettingsComponentMap[settings.layout.type]"
v-if="settings.layout.type !== 'circular'"
v-model="settings.layout.options"
:keyOptions="keysOptions"
@update:model-value="updateLayout(settings.layout.type)"
/>
<Field v-if="settings.layout.type === 'forceAtlas2'">
<Button variant="primary" @click="toggleFA2Layout">
<template #node:icon>
<div
:style="{
padding: '0 3px'
}"
>
<RunIcon v-if="!fa2Running" />
<StopIcon v-else /></div
></template>
{{ fa2Running ? 'Stop' : 'Start' }}
</Button>
</Field>
</Fold>
</Panel>
</PanelMenuWrapper>
</GraphEditorControls>
<div
ref="graph"
:style="{
height: '100%',
width: '100%'
}"
/>
</div>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import GraphEditorControls from '@/lib/GraphEditorControls.jsx'
import { PanelMenuWrapper, Panel, Fold, Section } from 'react-chart-editor'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
import Button from 'react-chart-editor/lib/components/widgets/Button'
import ColorscalePicker from 'react-chart-editor/lib/components/widgets/ColorscalePicker'
import ColorPicker from 'react-chart-editor/lib/components/widgets/ColorPicker'
import Field from 'react-chart-editor/lib/components/fields/Field'
import RandomLayoutSettings from '@/components/Graph/RandomLayoutSettings.vue'
import ForceAtlasLayoutSettings from '@/components/Graph/ForceAtlasLayoutSettings.vue'
import CirclePackLayoutSettings from '@/components/Graph/CirclePackLayoutSettings.vue'
import 'react-chart-editor/lib/react-chart-editor.css'
import FA2Layout from 'graphology-layout-forceatlas2/worker'
import forceAtlas2 from 'graphology-layout-forceatlas2'
import RunIcon from '@/components/svg/run.vue'
import StopIcon from '@/components/svg/stop.vue'
import {
buildNodes,
buildEdges,
updateNodes,
updateEdges
} from '@/lib/graphHelper'
import Graph from 'graphology'
import { circular, random, circlepack } from 'graphology-layout'
import Sigma from 'sigma'
import seedrandom from 'seedrandom'
export default {
components: {
GraphEditorControls: applyPureReactInVue(GraphEditorControls),
PanelMenuWrapper: applyPureReactInVue(PanelMenuWrapper),
Panel: applyPureReactInVue(Panel),
PanelSection: applyPureReactInVue(Section),
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field),
Fold: applyPureReactInVue(Fold),
ColorscalePicker: applyPureReactInVue(ColorscalePicker),
ColorPicker: applyPureReactInVue(ColorPicker),
Button: applyPureReactInVue(Button),
RunIcon,
StopIcon,
RandomLayoutSettings,
CirclePackLayoutSettings
},
props: {
dataSources: Object
},
data() {
return {
graph: new Graph(),
renderer: null,
fa2Layout: null,
fa2Running: false,
nodeSizeTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' },
{ label: 'Calculated', value: 'calculated' }
]),
edgeSizeTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' }
]),
nodeCalculatedSizeMethodOptions: markRaw([
{ label: 'Degree', value: 'degree' },
{ label: 'In degree', value: 'inDegree' },
{ label: 'Out degree', value: 'outDegree' }
]),
nodeSizeModeOptions: markRaw([
{ label: 'Area', value: 'area' },
{ label: 'Diameter', value: 'diameter' }
]),
nodeColorTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' },
{ label: 'Calculated', value: 'calculated' }
]),
edgeColorTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' }
]),
nodeCalculatedColorMethodOptions: markRaw([
{ label: 'Degree', value: 'degree' },
{ label: 'In degree', value: 'inDegree' },
{ label: 'Out degree', value: 'outDegree' }
]),
сolorAsOptions: markRaw([
{ label: 'Continious', value: 'continious' },
{ label: 'Categorical', value: 'categorical' }
]),
сolorscaleDirections: markRaw([
{ label: 'Normal', value: 'normal' },
{ label: 'Recersed', value: 'reversed' }
]),
colorSourceUsageOptions: markRaw([
{ label: 'Direct', value: 'direct' },
{ label: 'Map to', value: 'map_to' }
]),
visibilityOptions: markRaw([
{ label: 'Show', value: true },
{ label: 'Hide', value: false }
]),
layoutOptions: markRaw([
{ label: 'Circular', value: 'circular' },
{ label: 'Random', value: 'random' },
{ label: 'Circle pack', value: 'circlepack' },
{ label: 'ForceAtlas2', value: 'forceAtlas2' }
]),
layoutSettingsComponentMap: markRaw({
random: RandomLayoutSettings,
circlepack: CirclePackLayoutSettings,
forceAtlas2: ForceAtlasLayoutSettings
}),
settings: {
structure: {
nodeId: null,
objectType: null,
edgeSource: null,
edgeTarget: null
},
style: {
nodes: {
size: {
type: 'constant',
value: 16,
source: null,
scale: 1,
mode: 'diameter',
method: 'degree',
min: 0
},
color: {
type: 'constant',
value: '#1F77B4',
source: null,
sourceUsage: 'map_to',
colorscale: null,
colorscaleDirection: 'normal',
method: 'degree',
mode: 'continious'
},
label: {
source: null
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2,
source: null,
scale: 1,
min: 0
},
color: {
type: 'constant',
value: '#a2b1c6',
source: null,
sourceUsage: 'map_to',
colorscale: null,
colorscaleDirection: 'normal',
mode: 'continious'
},
label: {
source: null
}
}
},
layout: {
type: 'circular',
options: null
}
},
layoutOptionsArchive: {
random: null,
circlepack: null,
forceAtlas2: null
}
}
},
computed: {
records() {
if (!this.dataSources) {
return []
}
return this.dataSources[Object.keys(this.dataSources)[0] || 'doc'].map(
json => JSON.parse(json)
)
},
keysOptions() {
if (!this.dataSources) {
return []
}
const keySet = this.records.reduce((result, currentRecord) => {
Object.keys(currentRecord).forEach(key => result.add(key))
return result
}, new Set())
return Array.from(keySet)
}
},
watch: {
dataSources() {
if (this.dataSources) {
this.buildGraph()
}
},
'settings.structure': {
deep: true,
handler() {
this.buildGraph()
}
}
},
methods: {
buildGraph() {
if (this.renderer) {
this.renderer.kill()
}
this.graph.clear()
buildNodes(this.graph, this.dataSources, this.settings)
buildEdges(this.graph, this.dataSources, this.settings)
// Apply visual settings
updateNodes(this.graph, this.settings.style.nodes)
updateEdges(this.graph, this.settings.style.edges)
circular.assign(this.graph)
this.renderer = new Sigma(this.graph, this.$refs.graph, {
renderEdgeLabels: true,
allowInvalidContainer: true
})
},
updateStructure(attributeName, value) {
this.settings.structure[attributeName] = value
},
updateNodes(attributeName, value) {
const attributePath = attributeName.split('.')
attributePath.reduce((result, current, index) => {
if (index === attributePath.length - 1) {
return (result[current] = value)
} else {
return result[current]
}
}, this.settings.style.nodes)
updateNodes(this.graph, {
[attributePath[0]]: this.settings.style.nodes[attributePath[0]]
})
},
updateEdges(attributeName, value) {
const attributePath = attributeName.split('.')
attributePath.reduce((result, current, index) => {
if (index === attributePath.length - 1) {
return (result[current] = value)
} else {
return result[current]
}
}, this.settings.style.edges)
updateEdges(this.graph, {
[attributePath[0]]: this.settings.style.edges[attributePath[0]]
})
},
updateLayout(layoutType) {
if (layoutType !== this.settings.layout.type) {
const prevLayout = this.settings.layout.type
this.layoutOptionsArchive[prevLayout] = this.settings.layout.options
this.settings.layout.options = this.layoutOptionsArchive[layoutType]
if (layoutType === 'forceAtlas2' && !this.settings.layout.options) {
const sensibleSettings = forceAtlas2.inferSettings(this.graph)
this.settings.layout.options = {
adjustSizes: false,
barnesHutOptimize: false,
barnesHutTheta: 0.5,
edgeWeightInfluence: 1,
gravity: 1,
linLogMode: false,
outboundAttractionDistribution: false,
scalingRatio: 1,
slowDown: 1,
strongGravityMode: false,
...sensibleSettings
}
} else if (layoutType === 'random' && !this.settings.layout.options) {
this.settings.layout.options = {
seedValue: 1
}
} else if (
layoutType === 'circlepack' &&
!this.settings.layout.options
) {
this.settings.layout.options = {
seedValue: 1
}
}
this.settings.layout.type = layoutType
}
if (layoutType === 'circular') {
circular.assign(this.graph)
return
}
if (layoutType === 'random') {
random.assign(this.graph, {
rng: seedrandom(this.settings.layout.options.seedValue || 1)
})
return
}
if (layoutType === 'circlepack') {
this.graph.forEachNode(nodeId => {
this.graph.updateNode(nodeId, attributes => {
const newAttributes = { ...attributes }
// Delete old hierarchy attributes
Object.keys(newAttributes)
.filter(key => key.startsWith('hierarchyAttribute'))
.forEach(
hierarchyAttributeKey =>
delete newAttributes[hierarchyAttributeKey]
)
// Set new hierarchy attributes
this.settings.layout.options.hierarchyAttributes?.forEach(
(hierarchyAttribute, index) => {
newAttributes['hierarchyAttribute' + index] =
attributes.data[hierarchyAttribute]
}
)
return newAttributes
})
})
circlepack.assign(this.graph, {
hierarchyAttributes:
this.settings.layout.options.hierarchyAttributes?.map(
(_, index) => 'hierarchyAttribute' + index
) || [],
rng: seedrandom(this.settings.layout.options.seedValue || 1)
})
return
}
if (layoutType === 'forceAtlas2') {
if (this.fa2Layout) {
this.fa2Layout.kill()
}
this.fa2Layout = markRaw(
new FA2Layout(this.graph, {
getEdgeWeight: (_, attr) =>
attr.data[this.settings.layout.options.weightSource || 'weight'],
settings: this.settings.layout.options
})
)
}
},
toggleFA2Layout() {
if (this.fa2Layout.isRunning()) {
this.fa2Running = false
this.fa2Layout.stop()
} else {
this.fa2Running = true
this.fa2Layout.start()
}
}
}
}
</script>
<style scoped>
.plotly_editor > div {
display: flex !important;
}
:deep(.customPickerContainer) {
float: right;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div ref="graphContainer" class="chart-container">
<div v-show="!dataSources" class="warning chart-warning">
There is no data to build a graph. Run your SQL query and make sure the
result is not empty.
</div>
<div
class="graph"
:style="{
height: !dataSources ? 'calc(100% - 40px)' : '100%',
'background-color': 'white'
}"
>
<GraphEditor :dataSources="dataSources" />
</div>
</div>
</template>
<script>
import 'react-chart-editor/lib/react-chart-editor.css'
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events'
import GraphEditor from './GraphEditor.vue'
export default {
name: 'Graph',
components: { GraphEditor },
props: {
dataSources: Object,
initOptions: Object,
importToPngEnabled: Boolean,
importToSvgEnabled: Boolean
},
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'],
created() {
this.$emit('update:importToSvgEnabled', true)
},
mounted() {},
methods: {
getOptionsForSave() {},
async saveAsPng() {
const url = await this.prepareCopy()
this.$emit('loadingImageCompleted')
fIo.downloadFromUrl(url, 'chart')
},
async saveAsSvg() {
const url = await this.prepareCopy('svg')
fIo.downloadFromUrl(url, 'chart')
},
saveAsHtml() {},
async prepareCopy(type = 'png') {}
}
}
</script>
<style scoped>
.chart-container {
height: 100%;
}
.chart-warning {
height: 40px;
line-height: 40px;
border-bottom: 1px solid var(--color-border);
box-sizing: border-box;
}
.chart {
min-height: 242px;
}
:deep(.editor_controls .sidebar__item:before) {
width: 0;
}
</style>

View File

@@ -30,6 +30,14 @@
>
<pivot-icon />
</icon-button>
<icon-button
:active="mode === 'graph'"
tooltip="Switch to graph"
tooltipPosition="top-left"
@click="mode = 'graph'"
>
<graph-icon />
</icon-button>
<div class="side-tool-bar-divider" />
@@ -87,10 +95,12 @@
<script>
import Chart from './Chart'
import Pivot from './Pivot'
import Graph from './Graph'
import SideToolBar from '../SideToolBar'
import IconButton from '@/components/IconButton'
import ChartIcon from '@/components/svg/chart'
import PivotIcon from '@/components/svg/pivot'
import GraphIcon from '@/components/svg/graph.vue'
import HtmlIcon from '@/components/svg/html'
import ExportToSvgIcon from '@/components/svg/exportToSvg'
import PngIcon from '@/components/svg/png'
@@ -105,10 +115,12 @@ export default {
components: {
Chart,
Pivot,
Graph,
SideToolBar,
IconButton,
ChartIcon,
PivotIcon,
GraphIcon,
ExportToSvgIcon,
PngIcon,
HtmlIcon,