1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2025-12-07 02:28:54 +08:00

Compare commits

3 Commits

Author SHA1 Message Date
lana-k
4232f15c04 autostart, reset and fixes 2025-08-17 21:21:21 +02:00
lana-k
9d562d11b8 export and background 2025-06-09 21:08:51 +02:00
lana-k
54cdbbc8b9 wip 2025-06-06 21:24:04 +02:00
16 changed files with 1114 additions and 995 deletions

View File

@@ -4,7 +4,7 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@*": ["./src/*"] "@\/*": ["./src/*"]
} }
} }
} }

543
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"format": "prettier . --write" "format": "prettier . --write"
}, },
"dependencies": { "dependencies": {
"@sigma/export-image": "^3.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"codemirror": "^5.65.18", "codemirror": "^5.65.18",
"codemirror-editor-vue3": "^2.8.0", "codemirror-editor-vue3": "^2.8.0",

View File

@@ -0,0 +1,117 @@
<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="Slow down">
<NumericInput
:value="modelValue.slowDown"
:min="0"
@update="update('slowDown', $event)"
/>
</Field>
<Field label="Edge weight">
<Dropdown
:options="keyOptions"
:value="modelValue.weightSource"
@change="update('weightSource', $event)"
/>
</Field>
<Field v-show="modelValue.weightSource" label="Edge weight influence">
<NumericInput
:value="modelValue.edgeWeightInfluence"
@update="update('edgeWeightInfluence', $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,137 @@
<template>
<Field label="Color">
<RadioBlocks
:options="edgeColorTypeOptions"
:activeOption="modelValue.type"
@option-change="updateColorType"
/>
<Field v-if="modelValue.type === 'constant'">
<ColorPicker
:selectedColor="modelValue.value"
@color-change="updateSettings('value', $event)"
/>
</Field>
<template v-else>
<Field>
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
:value="modelValue.source"
@change="updateSettings('source', $event)"
/>
</Field>
<Field>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="modelValue.sourceUsage"
@option-change="updateSettings('sourceUsage', $event)"
/>
</Field>
<Field v-if="modelValue.sourceUsage === 'map_to'">
<ColorscalePicker
:selected="modelValue.colorscale"
className="colorscale-picker"
@colorscale-change="updateSettings('colorscale', $event)"
/>
</Field>
</template>
</Field>
<Field v-if="modelValue.type !== 'constant'" label="Color as">
<RadioBlocks
:options="сolorAsOptions"
:activeOption="modelValue.mode"
@option-change="updateSettings('mode', $event)"
/>
</Field>
<Field v-if="modelValue.type !== 'constant'" label="Colorscale direction">
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="modelValue.colorscaleDirection"
@option-change="updateSettings('colorscaleDirection', $event)"
/>
</Field>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
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 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Dropdown: applyPureReactInVue(Dropdown),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field),
ColorscalePicker: applyPureReactInVue(ColorscalePicker),
ColorPicker: applyPureReactInVue(ColorPicker)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
edgeColorTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' }
]),
с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' }
]),
defaultColorSettings: {
constant: { value: '#1F77B4' },
variable: {
source: null,
sourceUsage: 'map_to',
colorscale: null,
mode: 'categorical',
colorscaleDirection: 'normal'
}
}
}
},
methods: {
updateColorType(newColorType) {
const currentColorType = this.modelValue.type
this.defaultColorSettings[currentColorType] = this.modelValue
this.$emit('update:modelValue', {
type: newColorType,
...this.defaultColorSettings[newColorType]
})
},
updateSettings(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>
<style scoped>
:deep(.customPickerContainer) {
float: right;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<Field label="Size">
<RadioBlocks
:options="edgeSizeTypeOptions"
:activeOption="modelValue.type"
@option-change="updateSizeType"
/>
<Field>
<NumericInput
v-if="modelValue.type === 'constant'"
:value="modelValue.value"
:min="1"
@update="updateSettings('value', $event)"
/>
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
:value="modelValue.source"
@change="updateSettings('source', $event)"
/>
</Field>
</Field>
<template v-if="modelValue.type !== 'constant'">
<Field label="Size scale">
<NumericInput
:value="modelValue.scale"
@update="updateSettings('scale', $event)"
/>
</Field>
<Field label="Minimum size">
<NumericInput
:value="modelValue.min"
@update="updateSettings('min', $event)"
/>
</Field>
</template>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
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 Field from 'react-chart-editor/lib/components/fields/Field'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
edgeSizeTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' }
]),
defaultSizeSettings: {
constant: { value: 4 },
variable: { source: null, scale: 1, min: 1 }
}
}
},
methods: {
updateSizeType(newSizeType) {
const currentSizeType = this.modelValue.type
this.defaultSizeSettings[currentSizeType] = this.modelValue
this.$emit('update:modelValue', {
type: newSizeType,
...this.defaultSizeSettings[newSizeType]
})
},
updateSettings(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

@@ -1,55 +1,9 @@
<template> <template>
<Field label="Adjust sizes"> <Field label="Initial iterations">
<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 <NumericInput
:value="modelValue.barnesHutTheta" :value="modelValue.initialIterationsAmount"
@update="update('barnesHutTheta', $event)" :min="1"
/> @update="update('initialIterationsAmount', $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>
@@ -59,45 +13,17 @@
@update="update('scalingRatio', $event)" @update="update('scalingRatio', $event)"
/> />
</Field> </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> </template>
<script> <script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury' import { applyPureReactInVue } from 'veaury'
import Field from 'react-chart-editor/lib/components/fields/Field' 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 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' import 'react-chart-editor/lib/react-chart-editor.css'
export default { export default {
components: { components: {
Field: applyPureReactInVue(Field), Field: applyPureReactInVue(Field),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput) NumericInput: applyPureReactInVue(NumericInput)
}, },
props: { props: {
@@ -105,14 +31,6 @@ export default {
keyOptions: Array keyOptions: Array
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
data() {
return {
booleanOptions: markRaw([
{ label: 'Yes', value: true },
{ label: 'No', value: false }
])
}
},
methods: { methods: {
update(attributeName, value) { update(attributeName, value) {
this.$emit('update:modelValue', { this.$emit('update:modelValue', {

View File

@@ -0,0 +1,155 @@
<template>
<Field label="Color">
<RadioBlocks
:options="nodeColorTypeOptions"
:activeOption="modelValue.type"
@option-change="updateColorType"
/>
<Field v-if="modelValue.type === 'constant'">
<ColorPicker
:selectedColor="modelValue.value"
@color-change="updateSettings('value', $event)"
/>
</Field>
<template v-else>
<Field>
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
:value="modelValue.source"
@change="updateSettings('source', $event)"
/>
<Dropdown
v-if="modelValue.type === 'calculated'"
:options="nodeCalculatedColorMethodOptions"
:value="modelValue.method"
@change="updateSettings('method', $event)"
/>
</Field>
<Field>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="modelValue.sourceUsage"
@option-change="updateSettings('sourceUsage', $event)"
/>
</Field>
<Field v-if="modelValue.sourceUsage === 'map_to'">
<ColorscalePicker
:selected="modelValue.colorscale"
className="colorscale-picker"
@colorscale-change="updateSettings('colorscale', $event)"
/>
</Field>
</template>
</Field>
<Field v-if="modelValue.type !== 'constant'" label="Color as">
<RadioBlocks
:options="сolorAsOptions"
:activeOption="modelValue.mode"
@option-change="updateSettings('mode', $event)"
/>
</Field>
<Field v-if="modelValue.type !== 'constant'" label="Colorscale direction">
<RadioBlocks
:options="сolorscaleDirections"
:activeOption="modelValue.colorscaleDirection"
@option-change="updateSettings('colorscaleDirection', $event)"
/>
</Field>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
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 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Dropdown: applyPureReactInVue(Dropdown),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field),
ColorscalePicker: applyPureReactInVue(ColorscalePicker),
ColorPicker: applyPureReactInVue(ColorPicker)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
nodeColorTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' },
{ label: 'Calculated', value: 'calculated' }
]),
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' }
]),
defaultColorSettings: {
constant: { value: '#1F77B4' },
variable: {
source: null,
sourceUsage: 'map_to',
colorscale: null,
mode: 'categorical',
colorscaleDirection: 'normal'
},
calculated: {
method: 'degree',
sourceUsage: 'map_to',
colorscale: null,
mode: 'continious',
colorscaleDirection: 'normal'
}
}
}
},
methods: {
updateColorType(newColorType) {
const currentColorType = this.modelValue.type
this.defaultColorSettings[currentColorType] = this.modelValue
this.$emit('update:modelValue', {
type: newColorType,
...this.defaultColorSettings[newColorType]
})
},
updateSettings(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>
<style scoped>
:deep(.customPickerContainer) {
float: right;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<Field label="Size">
<RadioBlocks
:options="nodeSizeTypeOptions"
:activeOption="modelValue.type"
@option-change="updateSizeType"
/>
<Field>
<NumericInput
v-if="modelValue.type === 'constant'"
:value="modelValue.value"
:min="1"
@update="updateSettings('value', $event)"
/>
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
:value="modelValue.source"
@change="updateSettings('source', $event)"
/>
<Dropdown
v-if="modelValue.type === 'calculated'"
:options="nodeCalculatedSizeMethodOptions"
:value="modelValue.method"
@change="updateSettings('method', $event)"
/>
</Field>
</Field>
<template v-if="modelValue.type !== 'constant'">
<Field label="Size scale">
<NumericInput
:value="modelValue.scale"
@update="updateSettings('scale', $event)"
/>
</Field>
<Field label="Size mode">
<RadioBlocks
:options="nodeSizeModeOptions"
:activeOption="modelValue.mode"
@option-change="updateSettings('mode', $event)"
/>
</Field>
<Field label="Minimum size">
<NumericInput
:value="modelValue.min"
@update="updateSettings('min', $event)"
/>
</Field>
</template>
</template>
<script>
import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury'
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 Field from 'react-chart-editor/lib/components/fields/Field'
import 'react-chart-editor/lib/react-chart-editor.css'
export default {
components: {
Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field)
},
props: {
modelValue: Object,
keyOptions: Array
},
emits: ['update:modelValue'],
data() {
return {
nodeSizeTypeOptions: markRaw([
{ label: 'Constant', value: 'constant' },
{ label: 'Variable', value: 'variable' },
{ label: 'Calculated', value: 'calculated' }
]),
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' }
]),
defaultSizeSettings: {
constant: { value: 4 },
variable: { source: null, scale: 1, mode: 'diameter', min: 1 },
calculated: { method: 'degree', scale: 1, mode: 'diameter', min: 1 }
}
}
},
methods: {
updateSizeType(newSizeType) {
const currentSizeType = this.modelValue.type
this.defaultSizeSettings[currentSizeType] = this.modelValue
this.$emit('update:modelValue', {
type: newSizeType,
...this.defaultSizeSettings[newSizeType]
})
},
updateSettings(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

@@ -1,4 +1,3 @@
import { nanoid } from 'nanoid'
import { COLOR_PICKER_CONSTANTS } from 'react-colorscales' import { COLOR_PICKER_CONSTANTS } from 'react-colorscales'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
@@ -14,10 +13,10 @@ export function buildNodes(graph, dataSources, options) {
const nodes = dataSources[docColumn] const nodes = dataSources[docColumn]
.map(json => JSON.parse(json)) .map(json => JSON.parse(json))
.filter(item => item[objectType] === TYPE_NODE) .filter(item => item[objectType] === TYPE_NODE)
nodes.forEach(node => { nodes.forEach(node => {
graph.addNode(node[nodeId], { graph.addNode(node[nodeId], {
data: node data: node,
labelColor: options.style.nodes.label.color
}) })
}) })
} }
@@ -37,7 +36,8 @@ export function buildEdges(graph, dataSources, options) {
const target = edge[edgeTarget] const target = edge[edgeTarget]
if (graph.hasNode(source) && graph.hasNode(target)) { if (graph.hasNode(source) && graph.hasNode(target)) {
graph.addEdge(source, target, { graph.addEdge(source, target, {
data: edge data: edge,
labelColor: options.style.edges.label.color
}) })
} }
}) })
@@ -88,8 +88,8 @@ export function updateEdges(graph, attributeUpdates) {
} }
graph.forEachEdge((edgeId, attributes, source, target) => { graph.forEachEdge((edgeId, attributes, source, target) => {
graph.updateEdge(source, target, attributes => { graph.updateEdgeWithKey(edgeId, source, target, attr => {
const newAttributes = { ...attributes } const newAttributes = { ...attr }
changeMethods.forEach(method => method(newAttributes, edgeId)) changeMethods.forEach(method => method(newAttributes, edgeId))
return newAttributes return newAttributes
}) })
@@ -97,10 +97,11 @@ export function updateEdges(graph, attributeUpdates) {
} }
function getUpdateLabelMethod(labelSettings) { function getUpdateLabelMethod(labelSettings) {
const { source } = labelSettings const { source, color } = labelSettings
return attributes => { return attributes => {
const label = attributes.data[source] ?? '' const label = attributes.data[source] ?? ''
attributes.label = label.toString() attributes.label = label.toString()
attributes.labelColor = color
} }
} }
@@ -301,64 +302,6 @@ export function getOptionsFromDataSources(dataSources) {
})) }))
} }
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 { export default {
getOptionsFromDataSources, getOptionsFromDataSources
getOptionsForSave,
getImageDataUrl,
getHtml,
getChartData
} }

View File

@@ -45,11 +45,16 @@ export default {
props: { props: {
dataSources: Object, dataSources: Object,
initOptions: Object, initOptions: Object,
importToPngEnabled: Boolean, exportToPngEnabled: Boolean,
importToSvgEnabled: Boolean, exportToSvgEnabled: Boolean,
forPivot: Boolean forPivot: Boolean
}, },
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'], emits: [
'update:exportToSvgEnabled',
'update:exportToHtmlEnabled',
'update',
'loadingImageCompleted'
],
data() { data() {
return { return {
plotly, plotly,
@@ -102,7 +107,8 @@ export default {
}, },
{ deep: true } { deep: true }
) )
this.$emit('update:importToSvgEnabled', true) this.$emit('update:exportToSvgEnabled', true)
this.$emit('update:exportToHtmlEnabled', true)
}, },
mounted() { mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize) this.resizeObserver = new ResizeObserver(this.handleResize)

View File

@@ -39,6 +39,16 @@
</Field> </Field>
</Fold> </Fold>
</Panel> </Panel>
<Panel group="Style" name="General">
<Fold name="General">
<Field label="Background color">
<ColorPicker
:selectedColor="settings.style.backgroundColor"
@color-change="settings.style.backgroundColor = $event"
/>
</Field>
</Fold>
</Panel>
<Panel group="Style" name="Nodes"> <Panel group="Style" name="Nodes">
<Fold name="Nodes"> <Fold name="Nodes">
<Field label="Label"> <Field label="Label">
@@ -49,130 +59,23 @@
/> />
</Field> </Field>
<Field label="Size"> <Field label="Label color">
<RadioBlocks <ColorPicker
:options="nodeSizeTypeOptions" :selectedColor="settings.style.nodes.label.color"
:activeOption="settings.style.nodes.size.type" @color-change="updateNodes('label.color', $event)"
@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>
<Field <NodeSizeSettings
v-if="settings.style.nodes.color.type !== 'constant'" v-model="settings.style.nodes.size"
label="Colorscale direction" :keyOptions="keysOptions"
> @update:model-value="updateNodes('size', $event)"
<RadioBlocks />
:options="сolorscaleDirections" <NodeColorSettings
:activeOption="settings.style.nodes.color.colorscaleDirection" v-model="settings.style.nodes.color"
@option-change=" :keyOptions="keysOptions"
updateNodes('color.colorscaleDirection', $event) @update:model-value="updateNodes('color', $event)"
" />
/>
</Field>
</Fold> </Fold>
</Panel> </Panel>
@@ -194,110 +97,24 @@
/> />
</Field> </Field>
<Field label="Size"> <Field label="Label color">
<RadioBlocks <ColorPicker
:options="edgeSizeTypeOptions" :selectedColor="settings.style.edges.label.color"
:activeOption="settings.style.edges.size.type" @color-change="updateEdges('label.color', $event)"
@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>
<Field <EdgeSizeSettings
v-if="settings.style.edges.color.type !== 'constant'" v-model="settings.style.edges.size"
label="Colorscale direction" :keyOptions="keysOptions"
> @update:model-value="updateEdges('size', $event)"
<RadioBlocks />
:options="сolorscaleDirections"
:activeOption="settings.style.edges.color.colorscaleDirection" <EdgeColorSettings
@option-change=" v-model="settings.style.edges.color"
updateEdges('color.colorscaleDirection', $event) :keyOptions="keysOptions"
" @update:model-value="updateEdges('color', $event)"
/> />
</Field>
</Fold> </Fold>
</Panel> </Panel>
<Panel group="Style" name="Layout"> <Panel group="Style" name="Layout">
@@ -316,8 +133,19 @@
:keyOptions="keysOptions" :keyOptions="keysOptions"
@update:model-value="updateLayout(settings.layout.type)" @update:model-value="updateLayout(settings.layout.type)"
/> />
</Fold>
<Field v-if="settings.layout.type === 'forceAtlas2'"> <template v-if="settings.layout.type === 'forceAtlas2'">
<Fold name="Advanced layout settings">
<AdvancedForceAtlasLayoutSettings
v-model="settings.layout.options"
:keyOptions="keysOptions"
@update:model-value="updateLayout(settings.layout.type)"
/>
</Fold>
<div class="force-atlas-buttons">
<Button variant="secondary" @click="resetFA2LayoutSettings">
Reset
</Button>
<Button variant="primary" @click="toggleFA2Layout"> <Button variant="primary" @click="toggleFA2Layout">
<template #node:icon> <template #node:icon>
<div <div
@@ -326,12 +154,13 @@
}" }"
> >
<RunIcon v-if="!fa2Running" /> <RunIcon v-if="!fa2Running" />
<StopIcon v-else /></div <StopIcon v-else />
></template> </div>
</template>
{{ fa2Running ? 'Stop' : 'Start' }} {{ fa2Running ? 'Stop' : 'Start' }}
</Button> </Button>
</Field> </div>
</Fold> </template>
</Panel> </Panel>
</PanelMenuWrapper> </PanelMenuWrapper>
</GraphEditorControls> </GraphEditorControls>
@@ -339,7 +168,8 @@
ref="graph" ref="graph"
:style="{ :style="{
height: '100%', height: '100%',
width: '100%' width: '100%',
backgroundColor: settings.style.backgroundColor
}" }"
/> />
</div> </div>
@@ -350,21 +180,21 @@ import { markRaw } from 'vue'
import { applyPureReactInVue } from 'veaury' import { applyPureReactInVue } from 'veaury'
import GraphEditorControls from '@/lib/GraphEditorControls.jsx' import GraphEditorControls from '@/lib/GraphEditorControls.jsx'
import { PanelMenuWrapper, Panel, Fold, Section } from 'react-chart-editor' import { PanelMenuWrapper, Panel, Fold, Section } from 'react-chart-editor'
import NumericInput from 'react-chart-editor/lib/components/widgets/NumericInput' import 'react-chart-editor/lib/react-chart-editor.css'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown' import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks' 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 ColorPicker from 'react-chart-editor/lib/components/widgets/ColorPicker'
import Button from 'react-chart-editor/lib/components/widgets/Button'
import Field from 'react-chart-editor/lib/components/fields/Field' import Field from 'react-chart-editor/lib/components/fields/Field'
import RandomLayoutSettings from '@/components/Graph/RandomLayoutSettings.vue' import RandomLayoutSettings from '@/components/Graph/RandomLayoutSettings.vue'
import ForceAtlasLayoutSettings from '@/components/Graph/ForceAtlasLayoutSettings.vue' import ForceAtlasLayoutSettings from '@/components/Graph/ForceAtlasLayoutSettings.vue'
import AdvancedForceAtlasLayoutSettings from '@/components/Graph/AdvancedForceAtlasLayoutSettings.vue'
import CirclePackLayoutSettings from '@/components/Graph/CirclePackLayoutSettings.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 FA2Layout from 'graphology-layout-forceatlas2/worker'
import forceAtlas2 from 'graphology-layout-forceatlas2' import forceAtlas2 from 'graphology-layout-forceatlas2'
import RunIcon from '@/components/svg/run.vue' import RunIcon from '@/components/svg/run.vue'
import StopIcon from '@/components/svg/stop.vue' import StopIcon from '@/components/svg/stop.vue'
import { downloadAsPNG, drawOnCanvas } from '@sigma/export-image'
import { import {
buildNodes, buildNodes,
buildEdges, buildEdges,
@@ -375,6 +205,10 @@ import Graph from 'graphology'
import { circular, random, circlepack } from 'graphology-layout' import { circular, random, circlepack } from 'graphology-layout'
import Sigma from 'sigma' import Sigma from 'sigma'
import seedrandom from 'seedrandom' import seedrandom from 'seedrandom'
import NodeColorSettings from '@/components/Graph/NodeColorSettings.vue'
import NodeSizeSettings from '@/components/Graph/NodeSizeSettings.vue'
import EdgeSizeSettings from '@/components/Graph/EdgeSizeSettings.vue'
import EdgeColorSettings from '@/components/Graph/EdgeColorSettings.vue'
export default { export default {
components: { components: {
@@ -383,71 +217,34 @@ export default {
Panel: applyPureReactInVue(Panel), Panel: applyPureReactInVue(Panel),
PanelSection: applyPureReactInVue(Section), PanelSection: applyPureReactInVue(Section),
Dropdown: applyPureReactInVue(Dropdown), Dropdown: applyPureReactInVue(Dropdown),
NumericInput: applyPureReactInVue(NumericInput),
RadioBlocks: applyPureReactInVue(RadioBlocks), RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field), Field: applyPureReactInVue(Field),
Fold: applyPureReactInVue(Fold), Fold: applyPureReactInVue(Fold),
ColorscalePicker: applyPureReactInVue(ColorscalePicker),
ColorPicker: applyPureReactInVue(ColorPicker),
Button: applyPureReactInVue(Button), Button: applyPureReactInVue(Button),
ColorPicker: applyPureReactInVue(ColorPicker),
RunIcon, RunIcon,
StopIcon, StopIcon,
RandomLayoutSettings, RandomLayoutSettings,
CirclePackLayoutSettings CirclePackLayoutSettings,
NodeColorSettings,
NodeSizeSettings,
EdgeSizeSettings,
EdgeColorSettings,
AdvancedForceAtlasLayoutSettings
}, },
inject: ['tabLayout'],
props: { props: {
dataSources: Object dataSources: Object,
initOptions: Object
}, },
emits: ['update'],
data() { data() {
return { return {
graph: new Graph(), graph: new Graph({ multi: true, allowSelfLoops: true }),
renderer: null, renderer: null,
fa2Layout: null, fa2Layout: null,
fa2Running: false, fa2Running: false,
nodeSizeTypeOptions: markRaw([ checkIteration: null,
{ 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([ visibilityOptions: markRaw([
{ label: 'Show', value: true }, { label: 'Show', value: true },
{ label: 'Hide', value: false } { label: 'Hide', value: false }
@@ -464,66 +261,52 @@ export default {
forceAtlas2: ForceAtlasLayoutSettings forceAtlas2: ForceAtlasLayoutSettings
}), }),
settings: { settings: this.initOptions
structure: { ? JSON.parse(JSON.stringify(this.initOptions))
nodeId: null, : {
objectType: null, structure: {
edgeSource: null, nodeId: null,
edgeTarget: null objectType: null,
}, edgeSource: null,
style: { edgeTarget: null
nodes: {
size: {
type: 'constant',
value: 16,
source: null,
scale: 1,
mode: 'diameter',
method: 'degree',
min: 0
}, },
color: { style: {
type: 'constant', backgroundColor: 'white',
value: '#1F77B4', nodes: {
source: null, size: {
sourceUsage: 'map_to', type: 'constant',
colorscale: null, value: 4
colorscaleDirection: 'normal', },
method: 'degree', color: {
mode: 'continious' type: 'constant',
value: '#1F77B4'
},
label: {
source: null,
color: '#444444'
}
},
edges: {
showDirection: true,
size: {
type: 'constant',
value: 2
},
color: {
type: 'constant',
value: '#a2b1c6'
},
label: {
source: null,
color: '#a2b1c6'
}
}
}, },
label: { layout: {
source: null type: 'circular',
options: 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: { layoutOptionsArchive: {
random: null, random: null,
circlepack: null, circlepack: null,
@@ -536,9 +319,15 @@ export default {
if (!this.dataSources) { if (!this.dataSources) {
return [] return []
} }
return this.dataSources[Object.keys(this.dataSources)[0] || 'doc'].map( try {
json => JSON.parse(json) return (
) this.dataSources[Object.keys(this.dataSources)[0] || 'doc'].map(
json => JSON.parse(json)
) || []
)
} catch {
return []
}
}, },
keysOptions() { keysOptions() {
if (!this.dataSources) { if (!this.dataSources) {
@@ -558,11 +347,30 @@ export default {
this.buildGraph() this.buildGraph()
} }
}, },
settings: {
deep: true,
handler() {
this.$emit('update')
}
},
'settings.structure': { 'settings.structure': {
deep: true, deep: true,
handler() { handler() {
this.buildGraph() this.buildGraph()
} }
},
tabLayout: {
deep: true,
handler() {
if (this.tabLayout.dataView !== 'hidden' && this.renderer) {
this.renderer.scheduleRender()
}
}
}
},
mounted() {
if (this.dataSources) {
this.buildGraph()
} }
}, },
methods: { methods: {
@@ -579,11 +387,16 @@ export default {
updateNodes(this.graph, this.settings.style.nodes) updateNodes(this.graph, this.settings.style.nodes)
updateEdges(this.graph, this.settings.style.edges) updateEdges(this.graph, this.settings.style.edges)
circular.assign(this.graph) this.updateLayout(this.settings.layout.type)
this.renderer = new Sigma(this.graph, this.$refs.graph, { this.renderer = new Sigma(this.graph, this.$refs.graph, {
renderEdgeLabels: true, renderEdgeLabels: true,
allowInvalidContainer: true allowInvalidContainer: true,
labelColor: { attribute: 'labelColor', color: '#444444' },
edgeLabelColor: { attribute: 'labelColor', color: '#a2b1c6' }
}) })
if (this.settings.layout.type === 'forceAtlas2') {
this.autoRunFA2Layout()
}
}, },
updateStructure(attributeName, value) { updateStructure(attributeName, value) {
this.settings.structure[attributeName] = value this.settings.structure[attributeName] = value
@@ -617,41 +430,33 @@ export default {
}) })
}, },
updateLayout(layoutType) { updateLayout(layoutType) {
if (layoutType !== this.settings.layout.type) { const prevLayout = this.settings.layout.type
const prevLayout = this.settings.layout.type
// Change layout type? - restore layout settings or set default settings
if (layoutType !== prevLayout) {
this.layoutOptionsArchive[prevLayout] = this.settings.layout.options this.layoutOptionsArchive[prevLayout] = this.settings.layout.options
this.settings.layout.options = this.layoutOptionsArchive[layoutType] this.settings.layout.options = this.layoutOptionsArchive[layoutType]
if (layoutType === 'forceAtlas2' && !this.settings.layout.options) { if (!this.settings.layout.options) {
const sensibleSettings = forceAtlas2.inferSettings(this.graph) if (layoutType === 'forceAtlas2') {
this.settings.layout.options = { this.setRecommendedFA2Settings()
adjustSizes: false, } else if (['random', 'circlepack'].includes(layoutType)) {
barnesHutOptimize: false, this.settings.layout.options = {
barnesHutTheta: 0.5, seedValue: 1
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 this.settings.layout.type = layoutType
} }
// In any case kill FA2 if it exists
if (this.fa2Layout) {
if (this.fa2Layout.isRunning()) {
this.stopFA2Layout()
}
this.fa2Layout.kill()
}
if (layoutType === 'circular') { if (layoutType === 'circular') {
circular.assign(this.graph) circular.assign(this.graph)
return return
@@ -698,26 +503,108 @@ export default {
} }
if (layoutType === 'forceAtlas2') { if (layoutType === 'forceAtlas2') {
if (this.fa2Layout) { if (
this.fa2Layout.kill() !this.graph.someNode(
(nodeKey, attributes) =>
typeof attributes.x === 'number' &&
typeof attributes.y === 'number'
)
) {
circular.assign(this.graph)
} }
this.fa2Layout = markRaw( this.fa2Layout = markRaw(
new FA2Layout(this.graph, { new FA2Layout(this.graph, {
getEdgeWeight: (_, attr) => getEdgeWeight: (_, attr) =>
attr.data[this.settings.layout.options.weightSource || 'weight'], this.settings.layout.options.weightSource
? attr.data[this.settings.layout.options.weightSource]
: 1,
settings: this.settings.layout.options settings: this.settings.layout.options
}) })
) )
if (layoutType !== prevLayout) {
this.autoRunFA2Layout()
}
} }
}, },
toggleFA2Layout() { toggleFA2Layout() {
if (this.fa2Layout.isRunning()) { if (this.fa2Layout.isRunning()) {
this.fa2Running = false this.stopFA2Layout()
this.fa2Layout.stop()
} else { } else {
this.fa2Running = true this.fa2Running = true
this.fa2Layout.start() this.fa2Layout.start()
} }
},
stopFA2Layout() {
this.fa2Running = false
this.fa2Layout.stop()
if (this.checkIteration) {
this.fa2Layout.worker.removeEventListener(
'message',
this.checkIteration
)
this.checkIteration = null
}
},
autoRunFA2Layout() {
if (this.fa2Layout.isRunning()) {
this.stopFA2Layout()
}
let iteration = 1
this.checkIteration = () => {
if (
iteration === this.settings.layout.options.initialIterationsAmount
) {
this.stopFA2Layout()
}
iteration++
}
this.fa2Layout.worker.addEventListener('message', this.checkIteration)
this.fa2Running = true
this.fa2Layout.start()
},
setRecommendedFA2Settings() {
const sensibleSettings = forceAtlas2.inferSettings(this.graph)
this.settings.layout.options = {
initialIterationsAmount: 50,
adjustSizes: false,
barnesHutOptimize: false,
barnesHutTheta: 0.5,
edgeWeightInfluence: 0,
gravity: 1,
linLogMode: false,
outboundAttractionDistribution: false,
scalingRatio: 1,
slowDown: 1,
strongGravityMode: false,
...sensibleSettings
}
if (
[Infinity, -Infinity].includes(this.settings.layout.options.slowDown)
) {
this.settings.layout.options.slowDown = 1
}
},
resetFA2LayoutSettings() {
if (this.initOptions?.layout.type === 'forceAtlas2') {
this.settings.layout = JSON.parse(
JSON.stringify(this.initOptions.layout)
)
} else {
this.setRecommendedFA2Settings()
}
this.updateLayout(this.settings.layout.type)
},
saveAsPng() {
return downloadAsPNG(this.renderer, {
backgroundColor: this.settings.style.backgroundColor
})
},
prepareCopy() {
return drawOnCanvas(this.renderer, {
backgroundColor: this.settings.style.backgroundColor
})
} }
} }
} }
@@ -731,4 +618,14 @@ export default {
:deep(.customPickerContainer) { :deep(.customPickerContainer) {
float: right; float: right;
} }
.force-atlas-buttons {
display: flex;
width: 100%;
gap: 16px;
}
.force-atlas-buttons :deep(button) {
flex-grow: 1;
flex-basis: 0;
}
</style> </style>

View File

@@ -7,20 +7,22 @@
<div <div
class="graph" class="graph"
:style="{ :style="{
height: !dataSources ? 'calc(100% - 40px)' : '100%', height: !dataSources ? 'calc(100% - 40px)' : '100%'
'background-color': 'white'
}" }"
> >
<GraphEditor :dataSources="dataSources" /> <GraphEditor
ref="graphEditor"
:dataSources="dataSources"
:initOptions="initOptions"
@update="$emit('update')"
/>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import 'react-chart-editor/lib/react-chart-editor.css' import 'react-chart-editor/lib/react-chart-editor.css'
import fIo from '@/lib/utils/fileIo'
import events from '@/lib/utils/events' import events from '@/lib/utils/events'
import GraphEditor from './GraphEditor.vue' import GraphEditor from './GraphEditor.vue'
export default { export default {
@@ -29,30 +31,51 @@ export default {
props: { props: {
dataSources: Object, dataSources: Object,
initOptions: Object, initOptions: Object,
importToPngEnabled: Boolean, exportToPngEnabled: Boolean,
importToSvgEnabled: Boolean exportToSvgEnabled: Boolean,
exportToHtmlEnabled: Boolean
},
emits: [
'update:exportToSvgEnabled',
'update:exportToHtmlEnabled',
'update',
'loadingImageCompleted'
],
data() {
return {
resizeObserver: null
}
}, },
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'],
created() { created() {
this.$emit('update:importToSvgEnabled', true) this.$emit('update:exportToSvgEnabled', false)
this.$emit('update:exportToHtmlEnabled', false)
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.graphContainer)
},
beforeUnmount() {
this.resizeObserver.unobserve(this.$refs.graphContainer)
}, },
mounted() {},
methods: { methods: {
getOptionsForSave() {}, getOptionsForSave() {
return this.$refs.graphEditor.settings
},
async saveAsPng() { async saveAsPng() {
const url = await this.prepareCopy() await this.$refs.graphEditor.saveAsPng()
this.$emit('loadingImageCompleted') this.$emit('loadingImageCompleted')
fIo.downloadFromUrl(url, 'chart')
}, },
async prepareCopy() {
async saveAsSvg() { return await this.$refs.graphEditor.prepareCopy()
const url = await this.prepareCopy('svg')
fIo.downloadFromUrl(url, 'chart')
}, },
async handleResize() {
saveAsHtml() {}, const renderer = this.$refs.graphEditor.renderer
async prepareCopy(type = 'png') {} if (renderer) {
renderer.refresh()
renderer.getCamera().setState({ x: 0.5, y: 0.5 })
}
}
} }
} }
</script> </script>

View File

@@ -46,14 +46,15 @@ export default {
props: { props: {
dataSources: Object, dataSources: Object,
initOptions: Object, initOptions: Object,
importToPngEnabled: Boolean, exportToPngEnabled: Boolean,
importToSvgEnabled: Boolean exportToSvgEnabled: Boolean
}, },
emits: [ emits: [
'loadingImageCompleted', 'loadingImageCompleted',
'update', 'update',
'update:importToSvgEnabled', 'update:exportToSvgEnabled',
'update:importToPngEnabled' 'update:exportToPngEnabled',
'update:exportToHtmlEnabled'
], ],
data() { data() {
return { return {
@@ -110,11 +111,11 @@ export default {
immediate: true, immediate: true,
handler() { handler() {
this.$emit( this.$emit(
'update:importToPngEnabled', 'update:exportToPngEnabled',
this.pivotOptions.rendererName !== 'TSV Export' this.pivotOptions.rendererName !== 'TSV Export'
) )
this.$emit( this.$emit(
'update:importToSvgEnabled', 'update:exportToSvgEnabled',
this.viewStandartChart || this.viewCustomChart this.viewStandartChart || this.viewCustomChart
) )
events.send('viz_pivot.render', null, { events.send('viz_pivot.render', null, {
@@ -126,6 +127,9 @@ export default {
this.show() this.show()
} }
}, },
created() {
this.$emit('update:exportToHtmlEnabled', true)
},
mounted() { mounted() {
this.show() this.show()
// We need to detect resizing because plotly doesn't resize when resize its container // We need to detect resizing because plotly doesn't resize when resize its container

View File

@@ -4,9 +4,10 @@
<component <component
:is="mode" :is="mode"
ref="viewComponent" ref="viewComponent"
v-model:importToPngEnabled="importToPngEnabled" v-model:exportToPngEnabled="exportToPngEnabled"
v-model:importToSvgEnabled="importToSvgEnabled" v-model:exportToSvgEnabled="exportToSvgEnabled"
:initOptions="mode === initMode ? initOptions : undefined" v-model:exportToHtmlEnabled="exportToHtmlEnabled"
:initOptions="initOptionsByMode[mode]"
:data-sources="dataSource" :data-sources="dataSource"
@loading-image-completed="loadingImage = false" @loading-image-completed="loadingImage = false"
@update="$emit('update')" @update="$emit('update')"
@@ -42,7 +43,7 @@
<div class="side-tool-bar-divider" /> <div class="side-tool-bar-divider" />
<icon-button <icon-button
:disabled="!importToPngEnabled || loadingImage" :disabled="!exportToPngEnabled || loadingImage"
:loading="loadingImage" :loading="loadingImage"
tooltip="Save as PNG image" tooltip="Save as PNG image"
tooltipPosition="top-left" tooltipPosition="top-left"
@@ -52,7 +53,7 @@
</icon-button> </icon-button>
<icon-button <icon-button
ref="svgExportBtn" ref="svgExportBtn"
:disabled="!importToSvgEnabled" :disabled="!exportToSvgEnabled"
tooltip="Save as SVG" tooltip="Save as SVG"
tooltipPosition="top-left" tooltipPosition="top-left"
@click="saveAsSvg" @click="saveAsSvg"
@@ -62,6 +63,7 @@
<icon-button <icon-button
ref="htmlExportBtn" ref="htmlExportBtn"
:disabled="!exportToHtmlEnabled"
tooltip="Save as HTML" tooltip="Save as HTML"
tooltipPosition="top-left" tooltipPosition="top-left"
@click="saveAsHtml" @click="saveAsHtml"
@@ -136,12 +138,18 @@ export default {
data() { data() {
return { return {
mode: this.initMode || 'chart', mode: this.initMode || 'chart',
importToPngEnabled: true, exportToPngEnabled: true,
importToSvgEnabled: true, exportToSvgEnabled: true,
exportToHtmlEnabled: true,
loadingImage: false, loadingImage: false,
copyingImage: false, copyingImage: false,
preparingCopy: false, preparingCopy: false,
dataToCopy: null dataToCopy: null,
initOptionsByMode: {
chart: this.initMode === 'chart' ? this.initOptions : null,
pivot: this.initMode === 'pivot' ? this.initOptions : null,
graph: this.initMode === 'graph' ? this.initOptions : null
}
} }
}, },
computed: { computed: {
@@ -150,9 +158,10 @@ export default {
} }
}, },
watch: { watch: {
mode() { mode(newMode, oldMode) {
this.$emit('update') this.$emit('update')
this.importToPngEnabled = true this.exportToPngEnabled = true
this.initOptionsByMode[oldMode] = this.getOptionsForSave()
} }
}, },
methods: { methods: {

View File

@@ -68,7 +68,7 @@ import Splitpanes from '@/components/Splitpanes'
import SqlEditor from './SqlEditor' import SqlEditor from './SqlEditor'
import DataView from './DataView' import DataView from './DataView'
import RunResult from './RunResult' import RunResult from './RunResult'
import { nextTick } from 'vue' import { nextTick, computed } from 'vue'
import events from '@/lib/utils/events' import events from '@/lib/utils/events'
@@ -80,6 +80,11 @@ export default {
RunResult, RunResult,
Splitpanes Splitpanes
}, },
provide() {
return {
tabLayout: computed(() => this.tab.layout)
}
},
props: { props: {
tab: Object tab: Object
}, },