1
0
mirror of https://github.com/lana-k/sqliteviz.git synced 2026-02-04 15:38:55 +08:00

Compare commits

...

18 Commits

Author SHA1 Message Date
lana-k
713f5ac768 #43 graph 0.28.0 2025-12-23 21:41:17 +01:00
lana-k
5492609c3a update readme 2025-12-23 21:28:32 +01:00
lana-k
8bfd0f5944 tests 2025-12-23 21:15:44 +01:00
lana-k
a8006bcf52 tests 2025-12-17 21:26:57 +01:00
lana-k
1463f93bb0 tests for layouts 2025-12-13 17:57:48 +01:00
lana-k
5108495430 test for canvas 2025-12-13 13:00:13 +01:00
lana-k
d28968e539 tests 2025-12-07 19:56:16 +01:00
lana-k
68221cba6d tests 2025-11-15 14:29:41 +01:00
lana-k
65c1c18fcb tests 2025-11-08 22:23:38 +01:00
lana-k
d7db6a0f5d fix tests 2025-11-02 12:31:32 +01:00
lana-k
0a2af0bba3 events 2025-11-01 21:25:56 +01:00
lana-k
e4b35bac0a skip node if there is no node id 2025-11-01 19:48:22 +01:00
lana-k
3d1e822cdc link to docs, disable some settings, check result set 2025-11-01 15:49:34 +01:00
lana-k
3d6479be7a visualisation settings toggle 2025-10-28 22:51:13 +01:00
lana-k
218ab52ab3 autostart, reset and fixes 2025-10-28 13:39:38 +01:00
lana-k
f178937440 export and background 2025-10-28 13:39:38 +01:00
lana-k
411bd694c0 wip 2025-10-28 13:38:17 +01:00
lana-k
d2969de127 wip 2025-10-28 13:38:17 +01:00
37 changed files with 8374 additions and 4840 deletions

View File

@@ -9,7 +9,7 @@ of SQLite databases, CSV, JSON or NDJSON files.
With sqliteviz you can:
- run SQL queries against a SQLite database and create [Plotly][11] charts and pivot tables based on the result sets
- run SQL queries against a SQLite database and create [Plotly][11] charts, graphs and pivot tables based on the result sets
- import a CSV/JSON/NDJSON file into a SQLite database and visualize imported data
- export result set to CSV file
- manage inquiries and run them against different databases
@@ -33,7 +33,9 @@ It's a kind of middleground between [Plotly Falcon][1] and [Redash][2].
## Components
It is built on top of [react-chart-editor][3], [PivotTable.js][12], [sql.js][4] and [Vue-Codemirror][8] in [Vue.js][5]. CSV parsing is performed with [Papa Parse][9].
It is built on top of [react-chart-editor][3], [PivotTable.js][12], [sql.js][4]
and [Vue-Codemirror][8] in [Vue.js][5]. CSV parsing is performed with [Papa Parse][9].
Graphs are visualized with [Sigma.js][13] and [Graphology][14].
[1]: https://github.com/plotly/falcon
[2]: https://github.com/getredash/redash
@@ -47,3 +49,5 @@ It is built on top of [react-chart-editor][3], [PivotTable.js][12], [sql.js][4]
[10]: https://github.com/lana-k/sqliteviz/wiki/Predefined-queries
[11]: https://github.com/plotly/plotly.js
[12]: https://github.com/nicolaskruchten/pivottable
[13]: https://www.sigmajs.org/
[14]: https://graphology.github.io/

View File

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

6729
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "sqliteviz",
"version": "0.27.1",
"version": "0.28.0",
"license": "Apache-2.0",
"private": true,
"type": "module",
@@ -13,11 +13,15 @@
"format": "prettier . --write"
},
"dependencies": {
"@sigma/export-image": "^3.0.0",
"buffer": "^6.0.3",
"codemirror": "^5.65.18",
"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 +32,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

@@ -33,48 +33,6 @@ export default {
</script>
<style>
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Regular.woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-SemiBold.woff2');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Bold.woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Italic.woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-SemiBoldItalic.woff2');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-BoldItalic.woff2');
font-weight: 700;
font-style: italic;
}
#app,
.dialog,
input,

View File

@@ -4,3 +4,10 @@
font-size: 13px;
padding: 0 24px;
}
.data-view-warning {
height: 40px;
line-height: 40px;
border-bottom: 1px solid var(--color-border);
box-sizing: border-box;
}

View File

@@ -0,0 +1,45 @@
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Regular.woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-SemiBold.woff2');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Bold.woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-Italic.woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-SemiBoldItalic.woff2');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Open Sans';
src: url('@/assets/fonts/OpenSans-BoldItalic.woff2');
font-weight: 700;
font-style: italic;
}
a {
color: var(--color-accent-shade);
}

View File

@@ -0,0 +1,141 @@
<template>
<Field label="Scaling ratio" fieldContainerClassName="test_fa2_scaling">
<NumericInput
:value="modelValue.scalingRatio"
@update="update('scalingRatio', $event)"
/>
</Field>
<Field
label="Prevent overlapping"
fieldContainerClassName="test_fa2_adjustSizes"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.adjustSizes"
@option-change="update('adjustSizes', $event)"
/>
</Field>
<Field
label="Barnes-Hut optimize"
fieldContainerClassName="test_fa2_barnes_hut"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.barnesHutOptimize"
@option-change="update('barnesHutOptimize', $event)"
/>
</Field>
<Field
v-show="modelValue.barnesHutOptimize"
label="Barnes-Hut Theta"
fieldContainerClassName="test_fa2_barnes_theta"
>
<NumericInput
:value="modelValue.barnesHutTheta"
@update="update('barnesHutTheta', $event)"
/>
</Field>
<Field
label="Strong gravity mode"
fieldContainerClassName="test_fa2_strong_gravity"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.strongGravityMode"
@option-change="update('strongGravityMode', $event)"
/>
</Field>
<Field
label="Noack's LinLog model"
fieldContainerClassName="test_fa2_lin_log"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.linLogMode"
@option-change="update('linLogMode', $event)"
/>
</Field>
<Field
label="Outbound attraction distribution"
fieldContainerClassName="test_fa2_outbound_attraction"
>
<RadioBlocks
:options="booleanOptions"
:activeOption="modelValue.outboundAttractionDistribution"
@option-change="update('outboundAttractionDistribution', $event)"
/>
</Field>
<Field label="Slow down" fieldContainerClassName="test_fa2_slow_down">
<NumericInput
:value="modelValue.slowDown"
:min="0"
@update="update('slowDown', $event)"
/>
</Field>
<Field label="Edge weight">
<Dropdown
:options="keyOptions"
:value="modelValue.weightSource"
className="test_fa2_weight_source"
@change="update('weightSource', $event)"
/>
</Field>
<Field
v-show="modelValue.weightSource"
label="Edge weight influence"
fieldContainerClassName="test_fa2_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,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,148 @@
<template>
<Field label="Color" fieldContainerClassName="test_edge_color">
<RadioBlocks
:options="edgeColorTypeOptions"
:activeOption="modelValue.type"
@option-change="updateColorType"
/>
<Field
v-if="modelValue.type === 'constant'"
fieldContainerClassName="test_edge_color_value"
>
<ColorPicker
:selectedColor="modelValue.value"
@color-change="updateSettings('value', $event)"
/>
</Field>
<template v-else>
<Field fieldContainerClassName="test_edge_color_value">
<Dropdown
v-if="modelValue.type === 'variable'"
:options="keyOptions"
:value="modelValue.source"
@change="updateSettings('source', $event)"
/>
</Field>
<Field fieldContainerClassName="test_edge_color_mapping_mode">
<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' && modelValue.sourceUsage === 'map_to'"
label="Color as"
fieldContainerClassName="test_edge_color_as"
>
<RadioBlocks
:options="сolorAsOptions"
:activeOption="modelValue.mode"
@option-change="updateSettings('mode', $event)"
/>
</Field>
<Field
v-if="modelValue.type !== 'constant' && modelValue.sourceUsage === 'map_to'"
label="Colorscale direction"
fieldContainerClassName="test_edge_color_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" fieldContainerClassName="test_edge_size">
<RadioBlocks
:options="edgeSizeTypeOptions"
:activeOption="modelValue.type"
@option-change="updateSizeType"
/>
<Field fieldContainerClassName="test_edge_size_value">
<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" fieldContainerClassName="test_edge_size_scale">
<NumericInput
:value="modelValue.scale"
@update="updateSettings('scale', $event)"
/>
</Field>
<Field label="Minimum size" fieldContainerClassName="test_edge_size_min">
<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

@@ -0,0 +1,46 @@
<template>
<Field
label="Initial iterations"
fieldContainerClassName="test_fa2_iteration_amount"
>
<NumericInput
:value="modelValue.initialIterationsAmount"
:min="1"
@update="update('initialIterationsAmount', $event)"
/>
</Field>
<Field label="Gravity" fieldContainerClassName="test_fa2_gravity">
<NumericInput
:value="modelValue.gravity"
@update="update('gravity', $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,
keyOptions: Array
},
emits: ['update:modelValue'],
methods: {
update(attributeName, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[attributeName]: value
})
}
}
}
</script>

View File

@@ -0,0 +1,671 @@
<template>
<div :class="['plotly_editor', { with_controls: showViewSettings }]">
<GraphEditorControls v-show="showViewSettings">
<PanelMenuWrapper>
<Panel group="Structure" name="Graph">
<Fold name="Graph">
<Field>
Map your result set records to node and edge properties required
to build a graph. Learn more about result set requirements in the
<a href="https://sqliteviz.com/docs/graph/" target="_blank">
documentation</a
>.
</Field>
<Field label="Object type" ref="objectTypeField">
<Dropdown
:options="keysOptions"
:value="settings.structure.objectType"
className="test_object_type_select"
@change="updateStructure('objectType', $event)"
/>
<Field>
A field indicating if the record is node (value&nbsp;0) or edge
(value&nbsp;1).
</Field>
</Field>
<Field label="Node Id">
<Dropdown
:options="keysOptions"
:value="settings.structure.nodeId"
className="test_node_id_select"
@change="updateStructure('nodeId', $event)"
/>
<Field> A field keeping unique node identifier. </Field>
</Field>
<Field label="Edge source">
<Dropdown
:options="keysOptions"
:value="settings.structure.edgeSource"
className="test_edge_source_select"
@change="updateStructure('edgeSource', $event)"
/>
<Field>
A field keeping a node identifier where the edge starts.
</Field>
</Field>
<Field label="Edge target">
<Dropdown
:options="keysOptions"
:value="settings.structure.edgeTarget"
className="test_edge_target_select"
@change="updateStructure('edgeTarget', $event)"
/>
<Field>
A field keeping a node identifier where the edge ends.
</Field>
</Field>
</Fold>
</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">
<Fold name="Nodes">
<Field label="Label">
<Dropdown
:options="keysOptions"
:value="settings.style.nodes.label.source"
className="test_label_select"
@change="updateNodes('label.source', $event)"
/>
</Field>
<Field label="Label color">
<ColorPicker
:selectedColor="settings.style.nodes.label.color"
@color-change="updateNodes('label.color', $event)"
/>
</Field>
<NodeSizeSettings
v-model="settings.style.nodes.size"
:keyOptions="keysOptions"
@update:model-value="updateNodes('size', $event)"
/>
<NodeColorSettings
v-model="settings.style.nodes.color"
:keyOptions="keysOptions"
@update:model-value="updateNodes('color', $event)"
/>
</Fold>
</Panel>
<Panel group="Style" name="Edges">
<Fold name="Edges">
<Field
label="Direction"
fieldContainerClassName="test_edge_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"
className="test_edge_label_select"
@change="updateEdges('label.source', $event)"
/>
</Field>
<Field label="Label color">
<ColorPicker
:selectedColor="settings.style.edges.label.color"
@color-change="updateEdges('label.color', $event)"
/>
</Field>
<EdgeSizeSettings
v-model="settings.style.edges.size"
:keyOptions="keysOptions"
@update:model-value="updateEdges('size', $event)"
/>
<EdgeColorSettings
v-model="settings.style.edges.color"
:keyOptions="keysOptions"
@update:model-value="updateEdges('color', $event)"
/>
</Fold>
</Panel>
<Panel group="Style" name="Layout">
<Fold name="Layout">
<Field label="Algorithm">
<Dropdown
:options="layoutOptions"
:value="settings.layout.type"
:clearable="false"
className="test_layout_algorithm_select"
@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)"
/>
</Fold>
<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"
class="test_fa2_reset"
>
Reset
</Button>
<Button
variant="primary"
@click="toggleFA2Layout"
class="test_fa2_toggle"
>
<template #node:icon>
<div
:style="{
padding: '0 3px'
}"
>
<RunIcon v-if="!fa2Running" />
<StopIcon v-else />
</div>
</template>
{{ fa2Running ? 'Stop' : 'Start' }}
</Button>
</div>
</template>
</Panel>
</PanelMenuWrapper>
</GraphEditorControls>
<div
ref="graph"
class="test_graph_output"
:style="{
height: '100%',
width: '100%',
backgroundColor: settings.style.backgroundColor
}"
/>
</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 'react-chart-editor/lib/react-chart-editor.css'
import Dropdown from 'react-chart-editor/lib/components/widgets/Dropdown'
import RadioBlocks from 'react-chart-editor/lib/components/widgets/RadioBlocks'
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 RandomLayoutSettings from '@/components/Graph/RandomLayoutSettings.vue'
import ForceAtlasLayoutSettings from '@/components/Graph/ForceAtlasLayoutSettings.vue'
import AdvancedForceAtlasLayoutSettings from '@/components/Graph/AdvancedForceAtlasLayoutSettings.vue'
import CirclePackLayoutSettings from '@/components/Graph/CirclePackLayoutSettings.vue'
import FA2Layout from 'graphology-layout-forceatlas2/worker'
import * as forceAtlas2 from 'graphology-layout-forceatlas2'
import RunIcon from '@/components/svg/run.vue'
import StopIcon from '@/components/svg/stop.vue'
import { downloadAsPNG, drawOnCanvas } from '@sigma/export-image'
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'
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'
import events from '@/lib/utils/events'
export default {
components: {
GraphEditorControls: applyPureReactInVue(GraphEditorControls),
PanelMenuWrapper: applyPureReactInVue(PanelMenuWrapper),
Panel: applyPureReactInVue(Panel),
PanelSection: applyPureReactInVue(Section),
Dropdown: applyPureReactInVue(Dropdown),
RadioBlocks: applyPureReactInVue(RadioBlocks),
Field: applyPureReactInVue(Field),
Fold: applyPureReactInVue(Fold),
Button: applyPureReactInVue(Button),
ColorPicker: applyPureReactInVue(ColorPicker),
RunIcon,
StopIcon,
RandomLayoutSettings,
CirclePackLayoutSettings,
NodeColorSettings,
NodeSizeSettings,
EdgeSizeSettings,
EdgeColorSettings,
AdvancedForceAtlasLayoutSettings
},
inject: ['tabLayout'],
props: {
dataSources: Object,
initOptions: Object,
showViewSettings: Boolean
},
emits: ['update'],
data() {
return {
graph: new Graph({ multi: true, allowSelfLoops: true }),
renderer: null,
fa2Layout: null,
fa2Running: false,
checkIteration: null,
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: this.initOptions
? JSON.parse(JSON.stringify(this.initOptions))
: {
structure: {
nodeId: null,
objectType: null,
edgeSource: null,
edgeTarget: null
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
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'
}
}
},
layout: {
type: 'circular',
options: null
}
},
layoutOptionsArchive: {
random: null,
circlepack: null,
forceAtlas2: null
}
}
},
computed: {
records() {
if (!this.dataSources) {
return []
}
const firstColumnName = Object.keys(this.dataSources)[0]
try {
return (
this.dataSources[firstColumnName].map(json => JSON.parse(json)) || []
)
} catch {
return []
}
},
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: {
deep: true,
handler() {
this.$emit('update')
}
},
'settings.structure': {
deep: true,
handler() {
this.buildGraph()
}
},
'settings.layout.type': {
immediate: true,
handler() {
events.send('viz_graph.render', null, {
layout: this.settings.layout.type
})
}
},
tabLayout: {
deep: true,
handler() {
if (this.tabLayout.dataView !== 'hidden' && this.renderer) {
this.renderer.scheduleRender()
}
}
}
},
mounted() {
if (this.dataSources) {
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)
this.updateLayout(this.settings.layout.type)
this.renderer = new Sigma(this.graph, this.$refs.graph, {
renderEdgeLabels: true,
allowInvalidContainer: true
})
if (this.settings.layout.type === 'forceAtlas2') {
this.autoRunFA2Layout()
}
},
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) {
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.settings.layout.options = this.layoutOptionsArchive[layoutType]
if (!this.settings.layout.options) {
if (layoutType === 'forceAtlas2') {
this.setRecommendedFA2Settings()
} else if (['random', 'circlepack'].includes(layoutType)) {
this.settings.layout.options = {
seedValue: 1
}
}
}
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') {
circular.assign(this.graph)
return
}
if (layoutType === 'random') {
random.assign(this.graph, {
rng: seedrandom(this.settings.layout.options.seedValue)
})
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)
})
return
}
if (layoutType === 'forceAtlas2') {
if (
!this.graph.someNode(
(nodeKey, attributes) =>
typeof attributes.x === 'number' &&
typeof attributes.y === 'number'
)
) {
circular.assign(this.graph)
}
this.fa2Layout = markRaw(
new FA2Layout(this.graph, {
getEdgeWeight: (_, attr) =>
this.settings.layout.options.weightSource
? attr.data[this.settings.layout.options.weightSource]
: 1,
settings: this.settings.layout.options
})
)
if (layoutType !== prevLayout) {
this.autoRunFA2Layout()
}
}
},
toggleFA2Layout() {
if (this.fa2Layout.isRunning()) {
this.stopFA2Layout()
} else {
this.fa2Running = true
this.fa2Layout.start()
}
},
stopFA2Layout() {
this.fa2Running = false
this.fa2Layout.stop()
if (this.checkIteration) {
this.fa2Layout.worker.removeEventListener(
'message',
this.checkIteration
)
this.checkIteration = null
}
},
autoRunFA2Layout() {
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.default.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
})
}
}
}
</script>
<style scoped>
.plotly_editor.with_controls > div {
display: flex !important;
}
:deep(.customPickerContainer) {
float: right;
}
.force-atlas-buttons {
display: flex;
width: 100%;
gap: 16px;
}
.force-atlas-buttons :deep(button) {
flex-grow: 1;
flex-basis: 0;
}
</style>

View File

@@ -0,0 +1,178 @@
<template>
<Field label="Color" fieldContainerClassName="test_node_color">
<RadioBlocks
:options="nodeColorTypeOptions"
:activeOption="modelValue.type"
@option-change="updateColorType"
/>
<Field
v-if="modelValue.type === 'constant'"
fieldContainerClassName="test_node_color_value"
>
<ColorPicker
:selectedColor="modelValue.value"
@color-change="updateSettings('value', $event)"
/>
</Field>
<template v-else>
<Field fieldContainerClassName="test_node_color_value">
<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"
:clearable="false"
@change="updateSettings('method', $event)"
/>
</Field>
<Field
v-if="modelValue.type === 'variable'"
fieldContainerClassName="test_node_color_mapping_mode"
>
<RadioBlocks
:options="colorSourceUsageOptions"
:activeOption="modelValue.sourceUsage"
@option-change="updateSettings('sourceUsage', $event)"
/>
</Field>
<Field
v-if="
modelValue.sourceUsage === 'map_to' ||
modelValue.type === 'calculated'
"
>
<ColorscalePicker
:selected="modelValue.colorscale"
className="colorscale-picker"
@colorscale-change="updateSettings('colorscale', $event)"
/>
</Field>
</template>
</Field>
<Field
v-if="
modelValue.sourceUsage === 'map_to' || modelValue.type === 'calculated'
"
label="Color as"
fieldContainerClassName="test_node_color_as"
>
<RadioBlocks
:options="сolorAsOptions"
:activeOption="modelValue.mode"
@option-change="updateSettings('mode', $event)"
/>
</Field>
<Field
v-if="
modelValue.sourceUsage === 'map_to' || modelValue.type === 'calculated'
"
label="Colorscale direction"
fieldContainerClassName="test_node_color_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',
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,120 @@
<template>
<Field label="Size" fieldContainerClassName="test_node_size">
<RadioBlocks
:options="nodeSizeTypeOptions"
:activeOption="modelValue.type"
@option-change="updateSizeType"
/>
<Field fieldContainerClassName="test_node_size_value">
<NumericInput
v-if="modelValue.type === 'constant'"
:value="modelValue.value"
:min="1"
class="test_node_size_value"
@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"
:clearable="false"
@change="updateSettings('method', $event)"
/>
</Field>
</Field>
<template v-if="modelValue.type !== 'constant'">
<Field label="Size scale" fieldContainerClassName="test_node_size_scale">
<NumericInput
:value="modelValue.scale"
@update="updateSettings('scale', $event)"
/>
</Field>
<Field label="Size mode" fieldContainerClassName="test_node_size_mode">
<RadioBlocks
:options="nodeSizeModeOptions"
:activeOption="modelValue.mode"
@option-change="updateSettings('mode', $event)"
/>
</Field>
<Field label="Minimum size" fieldContainerClassName="test_node_size_min">
<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

@@ -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>

File diff suppressed because one or more lines are too long

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

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

@@ -0,0 +1,327 @@
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 dataSourceIsValid(dataSources) {
const docColumn = Object.keys(dataSources)[0]
if (!docColumn) {
return false
}
try {
const records = dataSources[docColumn].slice(0, 10)
records.forEach(record => {
const parsedRec = JSON.parse(record)
if (Object.keys(parsedRec).length < 2) {
throw new Error('The records must have at least 2 keys')
}
})
const firstRecord = JSON.parse(records[0])
if (
!Object.keys(firstRecord).some(key => {
return records
.map(record => JSON.parse(record)[key])
.every(value => value === 0 || value === 1)
})
) {
throw new Error(
'There must be a common key used as object type: 0 - node, 1 - edge'
)
}
return true
} catch (err) {
return false
}
}
export function buildNodes(graph, dataSources, options) {
const docColumn = Object.keys(dataSources)[0]
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 => {
if (node[nodeId]) {
graph.addNode(node[nodeId], {
data: node
})
}
})
}
}
export function buildEdges(graph, dataSources, options) {
const docColumn = Object.keys(dataSources)[0]
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.updateEdgeWithKey(edgeId, source, target, attr => {
const newAttributes = { ...attr }
changeMethods.forEach(method => method(newAttributes, edgeId))
return newAttributes
})
})
}
function getUpdateLabelMethod(labelSettings) {
const { source, color } = labelSettings
return attributes => {
const label = attributes.data[source] ?? ''
attributes.label = label.toString()
attributes.labelColor = color
}
}
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 attributes => {
attributes.size = getVariabledSize(
mode,
attributes.data[source],
scale,
min
)
}
} else {
return (attributes, nodeId) => {
attributes.size = getVariabledSize(
mode,
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 getVariabledSize(mode, value, scale, min) {
if (mode === 'diameter') {
return Math.max((value / 2) * scale, min / 2)
} else if (mode === 'area') {
return Math.max(Math.sqrt((value / 2) * scale), min / 2)
} else {
return Math.max(value * 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)) {
attributes.color = '#000000'
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
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)
}

View File

@@ -5,6 +5,7 @@ import store from '@/store'
import { createVfm, VueFinalModal, useVfm } from 'vue-final-modal'
import '@/assets/styles/variables.css'
import '@/assets/styles/typography.css'
import '@/assets/styles/buttons.css'
import '@/assets/styles/tables.css'
import '@/assets/styles/dialogs.css'

View File

@@ -1,6 +1,6 @@
<template>
<div ref="chartContainer" class="chart-container">
<div v-show="!dataSources" class="warning chart-warning">
<div v-show="!dataSources" class="warning data-view-warning">
There is no data to build a chart. Run your SQL query and make sure the
result is not empty.
</div>
@@ -20,6 +20,7 @@
:useResizeHandler="useResizeHandler"
:debug="true"
:advancedTraceTypeSelector="true"
:hideControls="!showViewSettings"
@update="update"
@render="onRender"
/>
@@ -45,11 +46,17 @@ export default {
props: {
dataSources: Object,
initOptions: Object,
importToPngEnabled: Boolean,
importToSvgEnabled: Boolean,
forPivot: Boolean
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
forPivot: Boolean,
showViewSettings: Boolean
},
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'],
emits: [
'update:exportToSvgEnabled',
'update:exportToHtmlEnabled',
'update',
'loadingImageCompleted'
],
data() {
return {
plotly,
@@ -80,6 +87,9 @@ export default {
dereference.default(this.state.data, this.dataSources)
this.updatePlotly()
}
},
showViewSettings() {
this.handleResize()
}
},
created() {
@@ -102,15 +112,16 @@ export default {
},
{ deep: true }
)
this.$emit('update:importToSvgEnabled', true)
this.$emit('update:exportToSvgEnabled', true)
this.$emit('update:exportToHtmlEnabled', true)
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.chartContainer)
if (this.dataSources) {
dereference.default(this.state.data, this.dataSources)
this.updatePlotly()
}
this.handleResize()
},
activated() {
this.useResizeHandler = true
@@ -123,6 +134,10 @@ export default {
},
methods: {
async handleResize() {
// Call updatePlotly twice because there is a small gap (for scrolling?)
// on right and bottom of the plot.
// After the second call it's good.
this.updatePlotly()
this.updatePlotly()
},
onRender() {
@@ -173,13 +188,6 @@ export default {
height: 100%;
}
.chart-warning {
height: 40px;
line-height: 40px;
border-bottom: 1px solid var(--color-border);
box-sizing: border-box;
}
.chart {
min-height: 242px;
}

View File

@@ -0,0 +1,125 @@
<template>
<div ref="graphContainer" class="graph-container">
<div v-show="!dataSources" class="warning data-view-warning no-data">
There is no data to build a graph. Run your SQL query and make sure the
result is not empty.
</div>
<div
v-show="!dataSourceIsValid"
class="warning data-view-warning invalid-data"
>
Result set is invalid for graph visualisation. Learn more in
<a href="https://sqliteviz.com/docs/graph/" target="_blank">
documentation</a
>.
</div>
<div
class="graph"
:style="{
height:
!dataSources || !dataSourceIsValid ? 'calc(100% - 40px)' : '100%'
}"
>
<GraphEditor
ref="graphEditor"
:dataSources="dataSources"
:initOptions="initOptions"
:showViewSettings="showViewSettings"
@update="$emit('update')"
/>
</div>
</div>
</template>
<script>
import 'react-chart-editor/lib/react-chart-editor.css'
import GraphEditor from '@/components/Graph/GraphEditor.vue'
import { dataSourceIsValid } from '@/lib/graphHelper'
export default {
name: 'Graph',
components: { GraphEditor },
props: {
dataSources: Object,
initOptions: Object,
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
exportToHtmlEnabled: Boolean,
showViewSettings: Boolean
},
emits: [
'update:exportToSvgEnabled',
'update:exportToHtmlEnabled',
'update:exportToPngEnabled',
'update:exportToClipboardEnabled',
'update',
'loadingImageCompleted'
],
data() {
return {
resizeObserver: null
}
},
created() {
this.$emit('update:exportToSvgEnabled', false)
this.$emit('update:exportToHtmlEnabled', false)
this.$emit('update:exportToPngEnabled', !!this.dataSources)
this.$emit('update:exportToClipboardEnabled', !!this.dataSources)
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.graphContainer)
},
beforeUnmount() {
this.resizeObserver.unobserve(this.$refs.graphContainer)
},
watch: {
async showViewSettings() {
await this.$nextTick()
this.handleResize()
},
dataSources() {
this.$emit('update:exportToPngEnabled', !!this.dataSources)
this.$emit('update:exportToClipboardEnabled', !!this.dataSources)
}
},
computed: {
dataSourceIsValid() {
return !this.dataSources || dataSourceIsValid(this.dataSources)
}
},
methods: {
getOptionsForSave() {
return this.$refs.graphEditor.settings
},
async saveAsPng() {
await this.$refs.graphEditor.saveAsPng()
this.$emit('loadingImageCompleted')
},
prepareCopy() {
return this.$refs.graphEditor.prepareCopy()
},
async handleResize() {
const renderer = this.$refs.graphEditor.renderer
if (renderer) {
renderer.refresh()
renderer.getCamera().animatedReset({ duration: 600 })
}
}
}
}
</script>
<style scoped>
.graph-container {
height: 100%;
}
.graph {
min-height: 242px;
}
:deep(.editor_controls .sidebar__item:before) {
width: 0;
}
</style>

View File

@@ -1,6 +1,5 @@
<template>
<div class="pivot-ui">
<div :class="{ collapsed }">
<div class="row">
<label>Columns</label>
<multiselect
@@ -129,10 +128,6 @@
</multiselect>
</div>
</div>
<span class="switcher" @click="collapsed = !collapsed">
{{ collapsed ? 'Show pivot settings' : 'Hide pivot settings' }}
</span>
</div>
</template>
<script>
@@ -163,7 +158,6 @@ export default {
const rendererName =
(this.modelValue && this.modelValue.rendererName) || 'Table'
return {
collapsed: false,
renderer: {
name: rendererName,
fun: $.pivotUtilities.renderers[rendererName]
@@ -291,9 +285,6 @@ export default {
margin-left: 12px;
flex-shrink: 0;
}
.collapsed {
display: none;
}
.switcher {
display: block;

View File

@@ -5,6 +5,7 @@
result is not empty.
</div>
<pivot-ui
v-show="showViewSettings"
v-model="pivotOptions"
:keyNames="columns"
@update="$emit('update')"
@@ -30,7 +31,7 @@ import fIo from '@/lib/utils/fileIo'
import $ from 'jquery'
import 'pivottable'
import 'pivottable/dist/pivot.css'
import PivotUi from './PivotUi'
import PivotUi from './PivotUi/index.vue'
import pivotHelper from './pivotHelper'
import Chart from '@/views/MainView/Workspace/Tabs/Tab/DataView/Chart'
import chartHelper from '@/lib/chartHelper'
@@ -46,14 +47,16 @@ export default {
props: {
dataSources: Object,
initOptions: Object,
importToPngEnabled: Boolean,
importToSvgEnabled: Boolean
exportToPngEnabled: Boolean,
exportToSvgEnabled: Boolean,
showViewSettings: Boolean
},
emits: [
'loadingImageCompleted',
'update',
'update:importToSvgEnabled',
'update:importToPngEnabled'
'update:exportToSvgEnabled',
'update:exportToPngEnabled',
'update:exportToHtmlEnabled'
],
data() {
return {
@@ -110,11 +113,11 @@ export default {
immediate: true,
handler() {
this.$emit(
'update:importToPngEnabled',
'update:exportToPngEnabled',
this.pivotOptions.rendererName !== 'TSV Export'
)
this.$emit(
'update:importToSvgEnabled',
'update:exportToSvgEnabled',
this.viewStandartChart || this.viewCustomChart
)
events.send('viz_pivot.render', null, {
@@ -124,8 +127,14 @@ export default {
},
pivotOptions() {
this.show()
},
showViewSettings() {
this.handleResize()
}
},
created() {
this.$emit('update:exportToHtmlEnabled', true)
},
mounted() {
this.show()
// We need to detect resizing because plotly doesn't resize when resize its container
@@ -218,12 +227,12 @@ export default {
async prepareCopy() {
if (this.viewCustomChart) {
return await this.$refs.customChart.prepareCopy()
return this.$refs.customChart.prepareCopy()
}
if (this.viewStandartChart) {
return await chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
return chartHelper.getImageDataUrl(this.$refs.pivotOutput, 'png')
}
return await pivotHelper.getPivotCanvas(this.$refs.pivotOutput)
return pivotHelper.getPivotCanvas(this.$refs.pivotOutput)
},
async saveAsSvg() {
@@ -317,4 +326,8 @@ export default {
.pivot-output:empty {
flex-grow: 0;
}
:deep(.js-plotly-plot) {
height: 100%;
}
</style>

View File

@@ -4,16 +4,20 @@
<component
:is="mode"
ref="viewComponent"
v-model:importToPngEnabled="importToPngEnabled"
v-model:importToSvgEnabled="importToSvgEnabled"
:initOptions="mode === initMode ? initOptions : undefined"
v-model:exportToPngEnabled="exportToPngEnabled"
v-model:exportToSvgEnabled="exportToSvgEnabled"
v-model:exportToHtmlEnabled="exportToHtmlEnabled"
v-model:exportToClipboardEnabled="exportToClipboardEnabled"
:initOptions="initOptionsByMode[mode]"
:data-sources="dataSource"
:showViewSettings="showViewSettings"
@loading-image-completed="loadingImage = false"
@update="$emit('update')"
/>
</div>
<side-tool-bar panel="dataView" @switch-to="$emit('switchTo', $event)">
<icon-button
ref="chartBtn"
:active="mode === 'chart'"
tooltip="Switch to chart"
tooltipPosition="top-left"
@@ -30,11 +34,33 @@
>
<pivot-icon />
</icon-button>
<icon-button
ref="graphBtn"
:active="mode === 'graph'"
tooltip="Switch to graph"
tooltipPosition="top-left"
@click="mode = 'graph'"
>
<graph-icon />
</icon-button>
<div class="side-tool-bar-divider" />
<icon-button
:disabled="!importToPngEnabled || loadingImage"
ref="settingsBtn"
:active="showViewSettings"
tooltip="Toggle visualisation settings visibility"
tooltipPosition="top-left"
@click="showViewSettings = !showViewSettings"
>
<settings-icon />
</icon-button>
<div class="side-tool-bar-divider" />
<icon-button
ref="pngExportBtn"
:disabled="!exportToPngEnabled || loadingImage"
:loading="loadingImage"
tooltip="Save as PNG image"
tooltipPosition="top-left"
@@ -44,7 +70,7 @@
</icon-button>
<icon-button
ref="svgExportBtn"
:disabled="!importToSvgEnabled"
:disabled="!exportToSvgEnabled"
tooltip="Save as SVG"
tooltipPosition="top-left"
@click="saveAsSvg"
@@ -54,6 +80,7 @@
<icon-button
ref="htmlExportBtn"
:disabled="!exportToHtmlEnabled"
tooltip="Save as HTML"
tooltipPosition="top-left"
@click="saveAsHtml"
@@ -62,6 +89,7 @@
</icon-button>
<icon-button
ref="copyToClipboardBtn"
:disabled="!exportToClipboardEnabled"
:loading="copyingImage"
tooltip="Copy visualisation to clipboard"
tooltipPosition="top-left"
@@ -87,10 +115,13 @@
<script>
import Chart from './Chart/index.vue'
import Pivot from './Pivot/index.vue'
import Graph from './Graph/index.vue'
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 SettingsIcon from '@/components/svg/settings.vue'
import HtmlIcon from '@/components/svg/html'
import ExportToSvgIcon from '@/components/svg/exportToSvg'
import PngIcon from '@/components/svg/png'
@@ -105,10 +136,13 @@ export default {
components: {
Chart,
Pivot,
Graph,
SideToolBar,
IconButton,
ChartIcon,
PivotIcon,
GraphIcon,
SettingsIcon,
ExportToSvgIcon,
PngIcon,
HtmlIcon,
@@ -124,13 +158,21 @@ export default {
data() {
return {
mode: this.initMode || 'chart',
importToPngEnabled: true,
importToSvgEnabled: true,
exportToPngEnabled: true,
exportToSvgEnabled: true,
exportToHtmlEnabled: true,
exportToClipboardEnabled: true,
loadingImage: false,
copyingImage: false,
preparingCopy: false,
dataToCopy: null,
showLoadingDialog: false
initOptionsByMode: {
chart: this.initMode === 'chart' ? this.initOptions : null,
pivot: this.initMode === 'pivot' ? this.initOptions : null,
graph: this.initMode === 'graph' ? this.initOptions : null
},
showLoadingDialog: false,
showViewSettings: true
}
},
computed: {
@@ -139,9 +181,11 @@ export default {
}
},
watch: {
mode() {
mode(newMode, oldMode) {
this.$emit('update')
this.importToPngEnabled = true
this.exportToPngEnabled = true
this.exportToClipboardEnabled = true
this.initOptionsByMode[oldMode] = this.getOptionsForSave()
}
},
methods: {
@@ -217,6 +261,8 @@ export default {
events.send(
this.mode === 'chart' || this.plotlyInPivot
? 'viz_plotly.export'
: this.mode === 'graph'
? 'viz_graph.export'
: 'viz_pivot.export',
null,
eventLabels

View File

@@ -63,7 +63,9 @@ export default {
border-left: 1px solid var(--color-border-light);
padding: 6px;
}
</style>
<style>
.side-tool-bar-divider {
width: 26px;
height: 1px;

View File

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

View File

@@ -42,8 +42,8 @@ export default {
) {
const stmt = [
'/*',
' * Your database is empty. In order to start building charts',
' * you should create a table and insert data into it.',
' * Your database is empty. In order to start building data visualisations',
' * you should create tables and insert data into them.',
' */',
'CREATE TABLE house',
'(',
@@ -54,7 +54,20 @@ export default {
"('Gryffindor', 100),",
"('Hufflepuff', 90),",
"('Ravenclaw', 95),",
"('Slytherin', 80);"
"('Slytherin', 80);",
'',
'CREATE TABLE student',
'(',
' id INTEGER,',
' name TEXT,',
' house TEXT',
');',
'INSERT INTO student VALUES',
"(1, 'Harry Potter', 'Gryffindor'),",
"(2, 'Ron Weasley', 'Gryffindor'),",
"(3, 'Draco Malfoy', 'Slytherin'),",
"(4, 'Luna Lovegood', 'Ravenclaw'),",
"(5, 'Cedric Diggory', 'Hufflepuff');"
].join('\n')
const tabId = await this.$store.dispatch('addTab', { query: stmt })

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

20
tests/testUtils.js Normal file
View File

@@ -0,0 +1,20 @@
export function waitCondition(condition, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
if (condition()) {
resolve()
return
}
const start = new Date().getTime()
const interval = setInterval(() => {
if (condition()) {
clearInterval(interval)
resolve()
} else {
if (new Date().getTime() - start > timeoutMs) {
clearInterval(interval)
reject()
}
}
}, 500)
})
}

View File

@@ -15,7 +15,6 @@ describe('Chart.vue', () => {
})
it('getOptionsForSave called with proper arguments', () => {
// mount the component
const wrapper = mount(Chart, {
global: {
mocks: { $store }
@@ -30,7 +29,6 @@ describe('Chart.vue', () => {
})
it('emits update when plotly updates', async () => {
// mount the component
const wrapper = mount(Chart, {
global: {
mocks: { $store }
@@ -48,7 +46,6 @@ describe('Chart.vue', () => {
points: [80]
}
// mount the component
const wrapper = mount(Chart, {
props: {
dataSources,
@@ -187,7 +184,8 @@ describe('Chart.vue', () => {
const wrapper = mount(Chart, {
attachTo: document.body,
props: {
dataSources
dataSources,
showViewSettings: true
},
global: {
mocks: { $store }
@@ -207,4 +205,41 @@ describe('Chart.vue', () => {
expect(wrapper.find('.Select__menu').text()).to.contain('name' + 'points')
wrapper.unmount()
})
it('hides and shows controls depending on showViewSettings and resizes the plot', async () => {
const wrapper = mount(Chart, {
attachTo: document.body,
props: {
dataSources: null,
showViewSettings: false
},
global: {
mocks: { $store }
}
})
// don't call flushPromises here, otherwize resize observer will be call to often
// which causes ResizeObserver loop completed with undelivered notifications.
await nextTick()
const plot = wrapper.find('.svg-container').wrapperElement
await flushPromises()
const initialPlotWidth = plot.scrollWidth
const initialPlotHeight = plot.scrollHeight
expect(wrapper.find('.plotly_editor .editor_controls').exists()).to.equal(
false
)
await wrapper.setProps({ showViewSettings: true })
await flushPromises()
expect(plot.scrollWidth).not.to.equal(initialPlotWidth)
expect(plot.scrollHeight).to.equal(initialPlotHeight)
expect(wrapper.find('.plotly_editor .editor_controls').exists()).to.equal(
true
)
wrapper.unmount()
})
})

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { mount } from '@vue/test-utils'
import DataView from '@/views/MainView/Workspace/Tabs/Tab/DataView'
import DataView from '@/views/MainView/Workspace/Tabs/Tab/DataView/index.vue'
import sinon from 'sinon'
import { nextTick } from 'vue'
import cIo from '@/lib/utils/clipboardIo'
@@ -15,7 +15,7 @@ describe('DataView.vue', () => {
it('emits update on mode changing', async () => {
const wrapper = mount(DataView, {
global: {
stubs: { chart: true }
mocks: { $store }
}
})
@@ -29,7 +29,10 @@ describe('DataView.vue', () => {
it('method getOptionsForSave calls the same method of the current view component', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store }
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
@@ -53,13 +56,28 @@ describe('DataView.vue', () => {
expect(wrapper.vm.getOptionsForSave()).to.eql({
here_are: 'pivot_settings'
})
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
const graph = wrapper.findComponent({ name: 'graph' }).vm
sinon
.stub(graph, 'getOptionsForSave')
.returns({ here_are: 'graph_settings' })
expect(wrapper.vm.getOptionsForSave()).to.eql({
here_are: 'graph_settings'
})
wrapper.unmount()
})
it('method saveAsSvg calls the same method of the current view component', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store }
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
@@ -87,13 +105,22 @@ describe('DataView.vue', () => {
// Export to svg
await svgBtn.trigger('click')
expect(pivot.saveAsSvg.calledOnce).to.equal(true)
// Switch to graph - svg disabled
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
expect(svgBtn.attributes('disabled')).to.not.equal(undefined)
wrapper.unmount()
})
it('method saveAsHtml calls the same method of the current view component', async () => {
const wrapper = mount(DataView, {
global: {
mocks: { $store }
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
@@ -114,9 +141,76 @@ describe('DataView.vue', () => {
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.spy(pivot, 'saveAsHtml')
// Export to svg
// Export to html
await htmlBtn.trigger('click')
expect(pivot.saveAsHtml.calledOnce).to.equal(true)
// Switch to graph - htmlBtn disabled
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
expect(htmlBtn.attributes('disabled')).to.not.equal(undefined)
wrapper.unmount()
})
it('method saveAsPng calls the same method of the current view component', async () => {
const clock = sinon.useFakeTimers()
const wrapper = mount(DataView, {
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
// Find chart and stub the method
const chart = wrapper.findComponent({ name: 'Chart' }).vm
sinon.stub(chart, 'saveAsPng').callsFake(() => {
chart.$emit('loadingImageCompleted')
})
// Export to png
const pngBtn = wrapper.findComponent({ ref: 'pngExportBtn' })
await pngBtn.trigger('click')
await clock.tick(0)
expect(chart.saveAsPng.calledOnce).to.equal(true)
// Switch to pivot
const pivotBtn = wrapper.findComponent({ ref: 'pivotBtn' })
await pivotBtn.trigger('click')
// Find pivot and stub the method
const pivot = wrapper.findComponent({ name: 'pivot' }).vm
sinon.stub(pivot, 'saveAsPng').callsFake(() => {
pivot.$emit('loadingImageCompleted')
})
// Export to png
await pngBtn.trigger('click')
await clock.tick(0)
expect(pivot.saveAsPng.calledOnce).to.equal(true)
// Switch to graph
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
// Save as png is disabled because there is no data
expect(pngBtn.attributes('disabled')).to.not.equal(undefined)
await wrapper.setProps({ dataSource: { doc: [] } })
// Find graph and stub the method
const graph = wrapper.findComponent({ name: 'graph' }).vm
sinon.stub(graph, 'saveAsPng').callsFake(() => {
graph.$emit('loadingImageCompleted')
})
// Export to png
await pngBtn.trigger('click')
await clock.tick(0)
expect(graph.saveAsPng.calledOnce).to.equal(true)
wrapper.unmount()
})
@@ -276,4 +370,52 @@ describe('DataView.vue', () => {
expect(wrapper.vm.copyToClipboard.calledOnce).to.equal(false)
wrapper.unmount()
})
it('saves visualisation options and restores them when switch between modes', async () => {
const wrapper = mount(DataView, {
props: {
initMode: 'graph',
initOptions: { test_options: 'graph_options_from_inquiery' }
},
global: {
mocks: { $store },
stubs: {
chart: true,
graph: true,
pivot: true
}
}
})
const getOptionsForSaveStub = sinon.stub(wrapper.vm, 'getOptionsForSave')
expect(
wrapper.findComponent({ name: 'graph' }).props('initOptions')
).to.eql({ test_options: 'graph_options_from_inquiery' })
getOptionsForSaveStub.returns({ test_options: 'latest_graph_options' })
const chartBtn = wrapper.findComponent({ ref: 'chartBtn' })
await chartBtn.trigger('click')
getOptionsForSaveStub.returns({ test_options: 'chart_settings' })
const pivotBtn = wrapper.findComponent({ ref: 'pivotBtn' })
await pivotBtn.trigger('click')
getOptionsForSaveStub.returns({ test_options: 'pivot_settings' })
await chartBtn.trigger('click')
expect(
wrapper.findComponent({ name: 'chart' }).props('initOptions')
).to.eql({ test_options: 'chart_settings' })
await pivotBtn.trigger('click')
expect(
wrapper.findComponent({ name: 'pivot' }).props('initOptions')
).to.eql({ test_options: 'pivot_settings' })
const graphBtn = wrapper.findComponent({ ref: 'graphBtn' })
await graphBtn.trigger('click')
expect(
wrapper.findComponent({ name: 'graph' }).props('initOptions')
).to.eql({ test_options: 'latest_graph_options' })
})
})

View File

@@ -0,0 +1,504 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { mount, flushPromises } from '@vue/test-utils'
import Graph from '@/views/MainView/Workspace/Tabs/Tab/DataView/Graph/index.vue'
function getPixels(canvas) {
const context = canvas.getContext('webgl2')
const width = context.canvas.width
const height = context.canvas.height
// Create arrays to hold the pixel data
const pixels = new Uint8Array(width * height * 4)
// Read pixels from canvas
context.readPixels(
0,
0,
width,
height,
context.RGBA,
context.UNSIGNED_BYTE,
pixels
)
return pixels.join(' ')
}
describe('Graph.vue', () => {
const $store = { state: { isWorkspaceVisible: true } }
afterEach(() => {
sinon.restore()
})
it('shows message when no data', () => {
const wrapper = mount(Graph, {
global: {
stubs: {
GraphEditor: true
}
},
attachTo: document.body
})
expect(wrapper.find('.no-data').isVisible()).to.equal(true)
expect(wrapper.find('.invalid-data').isVisible()).to.equal(false)
wrapper.unmount()
})
it('shows message when data is invalid', () => {
const wrapper = mount(Graph, {
props: {
dataSources: {
column1: ['value1', 'value2']
}
},
global: {
stubs: {
GraphEditor: true
}
},
attachTo: document.body
})
expect(wrapper.find('.no-data').isVisible()).to.equal(false)
expect(wrapper.find('.invalid-data').isVisible()).to.equal(true)
wrapper.unmount()
})
it('emits update when graph editor updates', async () => {
const wrapper = mount(Graph, {
global: {
stubs: {
GraphEditor: true
}
}
})
wrapper.findComponent({ ref: 'graphEditor' }).vm.$emit('update')
expect(wrapper.emitted('update')).to.have.lengthOf(1)
})
it('the graph resizes when the container resizes', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
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'
}
}
},
layout: {
type: 'circular',
options: null
}
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const container =
wrapper.find('.graph-container').wrapperElement.parentElement
const canvas = wrapper.find('canvas.sigma-nodes').wrapperElement
const initialContainerWidth = container.scrollWidth
const initialContainerHeight = container.scrollHeight
const initialCanvasWidth = canvas.scrollWidth
const initialCanvasHeight = canvas.scrollHeight
const newContainerWidth = initialContainerWidth * 2 || 1000
const newContainerHeight = initialContainerHeight * 2 || 2000
container.style.width = `${newContainerWidth}px`
container.style.height = `${newContainerHeight}px`
await flushPromises()
expect(canvas.scrollWidth).not.to.equal(initialCanvasWidth)
expect(canvas.scrollHeight).not.to.equal(initialCanvasHeight)
wrapper.unmount()
})
it('nodes and edges are rendered', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
showViewSettings: true,
dataSources: {
doc: [
'{"object_type": 0, "node_id": 1}',
'{"object_type": 0, "node_id": 2}',
'{"object_type": 1, "source": 1, "target": 2}'
]
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const container =
wrapper.find('.graph-container').wrapperElement.parentElement
container.style.height = '400px'
await wrapper
.find('.test_object_type_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
let options = wrapper.findAll('.Select__menu .Select__option')
await options[0].trigger('click')
const nodeCanvasPixelsBefore = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsBefore = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
await wrapper
.find('.test_node_id_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[1].trigger('click')
await wrapper
.find('.test_edge_source_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[2].trigger('click')
await wrapper
.find('.test_edge_target_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[3].trigger('click')
const nodeCanvasPixelsAfter = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsAfter = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
expect(nodeCanvasPixelsBefore).not.equal(nodeCanvasPixelsAfter)
expect(edgeCanvasPixelsBefore).not.equal(edgeCanvasPixelsAfter)
wrapper.unmount()
})
it('rerenders when dataSource changes but does not rerender if dataSources is empty', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
showViewSettings: true,
dataSources: {
doc: [
'{"object_type": 0, "node_id": 1}',
'{"object_type": 0, "node_id": 2}',
'{"object_type": 1, "source": 1, "target": 2}'
]
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const container =
wrapper.find('.graph-container').wrapperElement.parentElement
container.style.height = '400px'
await wrapper
.find('.test_object_type_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
let options = wrapper.findAll('.Select__menu .Select__option')
await options[0].trigger('click')
await wrapper
.find('.test_node_id_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[1].trigger('click')
await wrapper
.find('.test_edge_source_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[2].trigger('click')
await wrapper
.find('.test_edge_target_select.dropdown-container .Select__indicator')
.wrapperElement.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
options = wrapper.findAll('.Select__menu .Select__option')
await options[3].trigger('click')
const nodeCanvasPixelsBefore = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsBefore = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
await wrapper.setProps({
dataSources: {
doc: [
'{"object_type": 0, "node_id": 1}',
'{"object_type": 0, "node_id": 2}',
'{"object_type": 0, "node_id": 3}',
'{"object_type": 1, "source": 1, "target": 2}',
'{"object_type": 1, "source": 1, "target": 3}'
]
}
})
const nodeCanvasPixelsAfter = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsAfter = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
expect(nodeCanvasPixelsBefore).not.equal(nodeCanvasPixelsAfter)
expect(edgeCanvasPixelsBefore).not.equal(edgeCanvasPixelsAfter)
await wrapper.setProps({
dataSources: null
})
const nodeCanvasPixelsAfterEmtyData = getPixels(
wrapper.find('.test_graph_output canvas.sigma-nodes').wrapperElement
)
const edgeCanvasPixelsAfterEmtyData = getPixels(
wrapper.find('.test_graph_output canvas.sigma-edges').wrapperElement
)
expect(nodeCanvasPixelsAfterEmtyData).equal(nodeCanvasPixelsAfter)
expect(edgeCanvasPixelsAfterEmtyData).equal(edgeCanvasPixelsAfter)
wrapper.unmount()
})
it('saveAsPng', async () => {
const wrapper = mount(Graph, {
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
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'
}
}
},
layout: {
type: 'circular',
options: null
}
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
sinon.stub(wrapper.vm.$refs.graphEditor, 'saveAsPng')
await wrapper.vm.saveAsPng()
expect(wrapper.emitted().loadingImageCompleted.length).to.equal(1)
})
it('hides and shows controls depending on showViewSettings and resizes the graph', async () => {
const wrapper = mount(Graph, {
attachTo: document.body,
props: {
dataSources: {
doc: [
'{"object_type":0,"node_id":"Gryffindor"}',
'{"object_type":0,"node_id":"Hufflepuff"}'
]
},
initOptions: {
structure: {
nodeId: 'node_id',
objectType: 'object_type',
edgeSource: 'source',
edgeTarget: 'target'
},
style: {
backgroundColor: 'white',
nodes: {
size: {
type: 'constant',
value: 10
},
color: {
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'
}
}
},
layout: {
type: 'circular',
options: null
}
}
},
global: {
mocks: { $store },
provide: {
tabLayout: { dataView: 'above' }
}
}
})
const canvas = wrapper.find('canvas.sigma-nodes').wrapperElement
const initialPlotWidth = canvas.scrollWidth
const initialPlotHeight = canvas.scrollHeight
expect(
wrapper.find('.plotly_editor .editor_controls').isVisible()
).to.equal(false)
await wrapper.setProps({ showViewSettings: true })
await flushPromises()
expect(canvas.scrollWidth).not.to.equal(initialPlotWidth)
expect(canvas.scrollHeight).to.equal(initialPlotHeight)
expect(
wrapper.find('.plotly_editor .editor_controls').isVisible()
).to.equal(true)
wrapper.unmount()
})
})

View File

@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { mount, flushPromises } from '@vue/test-utils'
import Pivot from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot'
import Pivot from '@/views/MainView/Workspace/Tabs/Tab/DataView/Pivot/index.vue'
import chartHelper from '@/lib/chartHelper'
import fIo from '@/lib/utils/fileIo'
import $ from 'jquery'
@@ -586,4 +586,48 @@ describe('Pivot.vue', () => {
wrapper.unmount()
})
it('hides or shows controlls depending on showViewSettings and resizes standart chart', async () => {
const wrapper = mount(Pivot, {
global: {
mocks: { $store: { state: { isWorkspaceVisible: true } } }
},
props: {
dataSources: {
item: ['foo', 'bar', 'bar', 'bar'],
year: [2021, 2021, 2020, 2020]
},
initOptions: {
rows: ['item'],
cols: ['year'],
colOrder: 'key_a_to_z',
rowOrder: 'key_a_to_z',
aggregatorName: 'Count',
vals: [],
renderer: $.pivotUtilities.renderers['Bar Chart'],
rendererName: 'Bar Chart'
}
},
attachTo: container
})
expect(wrapper.find('.pivot-ui').isVisible()).to.equal(false)
await flushPromises()
const plot = wrapper.find('.svg-container').wrapperElement
const initialPlotHeight = plot.scrollHeight
await wrapper.setProps({ showViewSettings: true })
expect(wrapper.find('.pivot-ui').isVisible()).to.equal(true)
await flushPromises()
const plotAfterResize = wrapper.find('.svg-container').wrapperElement
expect(plotAfterResize.scrollWidth.scrollHeight).not.to.equal(
initialPlotHeight
)
wrapper.unmount()
})
})