mirror of
https://github.com/lana-k/sqliteviz.git
synced 2025-12-06 18:18:53 +08:00
Compare commits
8 Commits
0.27.1
...
0a2af0bba3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a2af0bba3 | ||
|
|
e4b35bac0a | ||
|
|
3d1e822cdc | ||
|
|
3d6479be7a | ||
|
|
218ab52ab3 | ||
|
|
f178937440 | ||
|
|
411bd694c0 | ||
|
|
d2969de127 |
@@ -4,7 +4,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@*": ["./src/*"]
|
"@\/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6729
package-lock.json
generated
6729
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,15 @@
|
|||||||
"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",
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
"dataurl-to-blob": "^0.0.1",
|
"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",
|
"html2canvas": "^1.1.4",
|
||||||
"jquery": "^3.6.0",
|
"jquery": "^3.6.0",
|
||||||
"nanoid": "^3.1.12",
|
"nanoid": "^3.1.12",
|
||||||
@@ -28,6 +32,8 @@
|
|||||||
"react": "^16.14.0",
|
"react": "^16.14.0",
|
||||||
"react-chart-editor": "^0.46.1",
|
"react-chart-editor": "^0.46.1",
|
||||||
"react-dom": "^16.14.0",
|
"react-dom": "^16.14.0",
|
||||||
|
"seedrandom": "^3.0.5",
|
||||||
|
"sigma": "^3.0.1",
|
||||||
"sql.js": "file:./lib/sql-js",
|
"sql.js": "file:./lib/sql-js",
|
||||||
"tiny-emitter": "^2.1.0",
|
"tiny-emitter": "^2.1.0",
|
||||||
"veaury": "^2.5.1",
|
"veaury": "^2.5.1",
|
||||||
|
|||||||
42
src/App.vue
42
src/App.vue
@@ -33,48 +33,6 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<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,
|
#app,
|
||||||
.dialog,
|
.dialog,
|
||||||
input,
|
input,
|
||||||
|
|||||||
@@ -4,3 +4,10 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-view-warning {
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|||||||
45
src/assets/styles/typography.css
Normal file
45
src/assets/styles/typography.css
Normal 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);
|
||||||
|
}
|
||||||
117
src/components/Graph/AdvancedForceAtlasLayoutSettings.vue
Normal file
117
src/components/Graph/AdvancedForceAtlasLayoutSettings.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<Field label="Scaling ratio">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.scalingRatio"
|
||||||
|
@update="update('scalingRatio', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Prevent overlapping">
|
||||||
|
<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="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>
|
||||||
77
src/components/Graph/CirclePackLayoutSettings.vue
Normal file
77
src/components/Graph/CirclePackLayoutSettings.vue
Normal 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>
|
||||||
143
src/components/Graph/EdgeColorSettings.vue
Normal file
143
src/components/Graph/EdgeColorSettings.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<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' && modelValue.sourceUsage === 'map_to'"
|
||||||
|
label="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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
93
src/components/Graph/EdgeSizeSettings.vue
Normal file
93
src/components/Graph/EdgeSizeSettings.vue
Normal 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>
|
||||||
43
src/components/Graph/ForceAtlasLayoutSettings.vue
Normal file
43
src/components/Graph/ForceAtlasLayoutSettings.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<Field label="Initial iterations">
|
||||||
|
<NumericInput
|
||||||
|
:value="modelValue.initialIterationsAmount"
|
||||||
|
:min="1"
|
||||||
|
@update="update('initialIterationsAmount', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="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>
|
||||||
161
src/components/Graph/NodeColorSettings.vue
Normal file
161
src/components/Graph/NodeColorSettings.vue
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<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 v-if="modelValue.type === 'variable'">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
118
src/components/Graph/NodeSizeSettings.vue
Normal file
118
src/components/Graph/NodeSizeSettings.vue
Normal 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>
|
||||||
34
src/components/Graph/RandomLayoutSettings.vue
Normal file
34
src/components/Graph/RandomLayoutSettings.vue
Normal 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>
|
||||||
40
src/components/svg/graph.vue
Normal file
40
src/components/svg/graph.vue
Normal 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>
|
||||||
31
src/components/svg/settings.vue
Normal file
31
src/components/svg/settings.vue
Normal file
File diff suppressed because one or more lines are too long
20
src/components/svg/stop.vue
Normal file
20
src/components/svg/stop.vue
Normal 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>
|
||||||
52
src/lib/GraphEditorControls.jsx
Normal file
52
src/lib/GraphEditorControls.jsx
Normal 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
|
||||||
344
src/lib/graphHelper.js
Normal file
344
src/lib/graphHelper.js
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
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,
|
||||||
|
labelColor: options.style.nodes.label.color
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEdges(graph, dataSources, options) {
|
||||||
|
const docColumn = Object.keys(dataSources)[0] || 'doc'
|
||||||
|
const { objectType, edgeSource, edgeTarget } = options.structure
|
||||||
|
|
||||||
|
if (objectType && edgeSource && edgeTarget) {
|
||||||
|
const edges = dataSources[docColumn]
|
||||||
|
.map(json => JSON.parse(json))
|
||||||
|
.filter(item => item[objectType] === TYPE_EDGE)
|
||||||
|
|
||||||
|
edges.forEach(edge => {
|
||||||
|
const source = edge[edgeSource]
|
||||||
|
const target = edge[edgeTarget]
|
||||||
|
if (graph.hasNode(source) && graph.hasNode(target)) {
|
||||||
|
graph.addEdge(source, target, {
|
||||||
|
data: edge,
|
||||||
|
labelColor: options.style.edges.label.color
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exactMatch = normalizedColorscale.find(
|
||||||
|
([value]) => value === normalizedValue
|
||||||
|
)
|
||||||
|
if (exactMatch) {
|
||||||
|
attributes.color = tinycolor(exactMatch[1]).toHexString()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightColorIndex = normalizedColorscale.findIndex(
|
||||||
|
([value]) => value >= normalizedValue
|
||||||
|
)
|
||||||
|
const leftColorIndex = (rightColorIndex || 1) - 1
|
||||||
|
const right = normalizedColorscale[rightColorIndex]
|
||||||
|
const left = normalizedColorscale[leftColorIndex]
|
||||||
|
const interpolationFactor =
|
||||||
|
(normalizedValue - left[0]) / (right[0] - left[0])
|
||||||
|
|
||||||
|
const r0 = left[1].r
|
||||||
|
const g0 = left[1].g
|
||||||
|
const b0 = left[1].b
|
||||||
|
const r1 = right[1].r
|
||||||
|
const g1 = right[1].g
|
||||||
|
const b1 = right[1].b
|
||||||
|
|
||||||
|
attributes.color = tinycolor({
|
||||||
|
r: r0 + interpolationFactor * (r1 - r0),
|
||||||
|
g: g0 + interpolationFactor * (g1 - g0),
|
||||||
|
b: b0 + interpolationFactor * (b1 - b0)
|
||||||
|
}).toHexString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeValueScale(graph, sourceGetter) {
|
||||||
|
const scaleSet = graph.reduceNodes((res, nodeId, attributes) => {
|
||||||
|
res.add(sourceGetter(nodeId, attributes))
|
||||||
|
return res
|
||||||
|
}, new Set())
|
||||||
|
return Array.from(scaleSet).sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdgeValueScale(graph, sourceGetter) {
|
||||||
|
const scaleSet = graph.reduceEdges((res, edgeId, attributes) => {
|
||||||
|
res.add(sourceGetter(edgeId, attributes))
|
||||||
|
return res
|
||||||
|
}, new Set())
|
||||||
|
return Array.from(scaleSet).sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOptionsFromDataSources(dataSources) {
|
||||||
|
if (!dataSources) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(dataSources).map(name => ({
|
||||||
|
value: name,
|
||||||
|
label: name
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getOptionsFromDataSources
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import store from '@/store'
|
|||||||
import { createVfm, VueFinalModal, useVfm } from 'vue-final-modal'
|
import { createVfm, VueFinalModal, useVfm } from 'vue-final-modal'
|
||||||
|
|
||||||
import '@/assets/styles/variables.css'
|
import '@/assets/styles/variables.css'
|
||||||
|
import '@/assets/styles/typography.css'
|
||||||
import '@/assets/styles/buttons.css'
|
import '@/assets/styles/buttons.css'
|
||||||
import '@/assets/styles/tables.css'
|
import '@/assets/styles/tables.css'
|
||||||
import '@/assets/styles/dialogs.css'
|
import '@/assets/styles/dialogs.css'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="chartContainer" class="chart-container">
|
<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
|
There is no data to build a chart. Run your SQL query and make sure the
|
||||||
result is not empty.
|
result is not empty.
|
||||||
</div>
|
</div>
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
:useResizeHandler="useResizeHandler"
|
:useResizeHandler="useResizeHandler"
|
||||||
:debug="true"
|
:debug="true"
|
||||||
:advancedTraceTypeSelector="true"
|
:advancedTraceTypeSelector="true"
|
||||||
|
:hideControls="!showViewSettings"
|
||||||
@update="update"
|
@update="update"
|
||||||
@render="onRender"
|
@render="onRender"
|
||||||
/>
|
/>
|
||||||
@@ -45,11 +46,17 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
dataSources: Object,
|
dataSources: Object,
|
||||||
initOptions: Object,
|
initOptions: Object,
|
||||||
importToPngEnabled: Boolean,
|
exportToPngEnabled: Boolean,
|
||||||
importToSvgEnabled: Boolean,
|
exportToSvgEnabled: Boolean,
|
||||||
forPivot: Boolean
|
forPivot: Boolean,
|
||||||
|
showViewSettings: Boolean
|
||||||
},
|
},
|
||||||
emits: ['update:importToSvgEnabled', 'update', 'loadingImageCompleted'],
|
emits: [
|
||||||
|
'update:exportToSvgEnabled',
|
||||||
|
'update:exportToHtmlEnabled',
|
||||||
|
'update',
|
||||||
|
'loadingImageCompleted'
|
||||||
|
],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
plotly,
|
plotly,
|
||||||
@@ -80,6 +87,9 @@ export default {
|
|||||||
dereference.default(this.state.data, this.dataSources)
|
dereference.default(this.state.data, this.dataSources)
|
||||||
this.updatePlotly()
|
this.updatePlotly()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
showViewSettings() {
|
||||||
|
this.handleResize()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
@@ -102,7 +112,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)
|
||||||
@@ -173,13 +184,6 @@ export default {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-warning {
|
|
||||||
height: 40px;
|
|
||||||
line-height: 40px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
min-height: 242px;
|
min-height: 242px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<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">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.structure.objectType"
|
||||||
|
@change="updateStructure('objectType', $event)"
|
||||||
|
/>
|
||||||
|
<Field>
|
||||||
|
A field indicating if the record is node (value 0) or edge
|
||||||
|
(value 1).
|
||||||
|
</Field>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Node Id">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.structure.nodeId"
|
||||||
|
@change="updateStructure('nodeId', $event)"
|
||||||
|
/>
|
||||||
|
<Field> A field keeping unique node identifier. </Field>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Edge source">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.structure.edgeSource"
|
||||||
|
@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"
|
||||||
|
@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"
|
||||||
|
@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">
|
||||||
|
<RadioBlocks
|
||||||
|
:options="visibilityOptions"
|
||||||
|
:activeOption="settings.style.edges.showDirection"
|
||||||
|
@option-change="updateEdges('showDirection', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Label">
|
||||||
|
<Dropdown
|
||||||
|
:options="keysOptions"
|
||||||
|
:value="settings.style.edges.label.source"
|
||||||
|
@change="updateEdges('label.source', $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="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"
|
||||||
|
@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">
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" @click="toggleFA2Layout">
|
||||||
|
<template #node:icon>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
padding: '0 3px'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<RunIcon v-if="!fa2Running" />
|
||||||
|
<StopIcon v-else />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ fa2Running ? 'Stop' : 'Start' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Panel>
|
||||||
|
</PanelMenuWrapper>
|
||||||
|
</GraphEditorControls>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="graph"
|
||||||
|
: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 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 []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
this.dataSources[Object.keys(this.dataSources)[0] || 'doc'].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,
|
||||||
|
labelColor: { attribute: 'labelColor', color: '#444444' },
|
||||||
|
edgeLabelColor: { attribute: 'labelColor', color: '#a2b1c6' }
|
||||||
|
})
|
||||||
|
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 || 1)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutType === 'circlepack') {
|
||||||
|
this.graph.forEachNode(nodeId => {
|
||||||
|
this.graph.updateNode(nodeId, attributes => {
|
||||||
|
const newAttributes = { ...attributes }
|
||||||
|
// Delete old hierarchy attributes
|
||||||
|
Object.keys(newAttributes)
|
||||||
|
.filter(key => key.startsWith('hierarchyAttribute'))
|
||||||
|
.forEach(
|
||||||
|
hierarchyAttributeKey =>
|
||||||
|
delete newAttributes[hierarchyAttributeKey]
|
||||||
|
)
|
||||||
|
// Set new hierarchy attributes
|
||||||
|
this.settings.layout.options.hierarchyAttributes?.forEach(
|
||||||
|
(hierarchyAttribute, index) => {
|
||||||
|
newAttributes['hierarchyAttribute' + index] =
|
||||||
|
attributes.data[hierarchyAttribute]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return newAttributes
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
circlepack.assign(this.graph, {
|
||||||
|
hierarchyAttributes:
|
||||||
|
this.settings.layout.options.hierarchyAttributes?.map(
|
||||||
|
(_, index) => 'hierarchyAttribute' + index
|
||||||
|
) || [],
|
||||||
|
rng: seedrandom(this.settings.layout.options.seedValue || 1)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutType === 'forceAtlas2') {
|
||||||
|
if (
|
||||||
|
!this.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() {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
||||||
114
src/views/MainView/Workspace/Tabs/Tab/DataView/Graph/index.vue
Normal file
114
src/views/MainView/Workspace/Tabs/Tab/DataView/Graph/index.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="graphContainer" class="graph-container">
|
||||||
|
<div v-show="!dataSources" class="warning data-view-warning">
|
||||||
|
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">
|
||||||
|
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 './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',
|
||||||
|
'loadingImageCompleted'
|
||||||
|
],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
resizeObserver: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
async showViewSettings() {
|
||||||
|
await this.$nextTick()
|
||||||
|
this.handleResize()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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')
|
||||||
|
},
|
||||||
|
async prepareCopy() {
|
||||||
|
return await 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>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="pivot-ui">
|
<div class="pivot-ui">
|
||||||
<div :class="{ collapsed }">
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Columns</label>
|
<label>Columns</label>
|
||||||
<multiselect
|
<multiselect
|
||||||
@@ -129,10 +128,6 @@
|
|||||||
</multiselect>
|
</multiselect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="switcher" @click="collapsed = !collapsed">
|
|
||||||
{{ collapsed ? 'Show pivot settings' : 'Hide pivot settings' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -163,7 +158,6 @@ export default {
|
|||||||
const rendererName =
|
const rendererName =
|
||||||
(this.modelValue && this.modelValue.rendererName) || 'Table'
|
(this.modelValue && this.modelValue.rendererName) || 'Table'
|
||||||
return {
|
return {
|
||||||
collapsed: false,
|
|
||||||
renderer: {
|
renderer: {
|
||||||
name: rendererName,
|
name: rendererName,
|
||||||
fun: $.pivotUtilities.renderers[rendererName]
|
fun: $.pivotUtilities.renderers[rendererName]
|
||||||
@@ -291,9 +285,6 @@ export default {
|
|||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.collapsed {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switcher {
|
.switcher {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
result is not empty.
|
result is not empty.
|
||||||
</div>
|
</div>
|
||||||
<pivot-ui
|
<pivot-ui
|
||||||
|
v-show="showViewSettings"
|
||||||
v-model="pivotOptions"
|
v-model="pivotOptions"
|
||||||
:keyNames="columns"
|
:keyNames="columns"
|
||||||
@update="$emit('update')"
|
@update="$emit('update')"
|
||||||
@@ -46,14 +47,16 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
dataSources: Object,
|
dataSources: Object,
|
||||||
initOptions: Object,
|
initOptions: Object,
|
||||||
importToPngEnabled: Boolean,
|
exportToPngEnabled: Boolean,
|
||||||
importToSvgEnabled: Boolean
|
exportToSvgEnabled: Boolean,
|
||||||
|
showViewSettings: Boolean
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'loadingImageCompleted',
|
'loadingImageCompleted',
|
||||||
'update',
|
'update',
|
||||||
'update:importToSvgEnabled',
|
'update:exportToSvgEnabled',
|
||||||
'update:importToPngEnabled'
|
'update:exportToPngEnabled',
|
||||||
|
'update:exportToHtmlEnabled'
|
||||||
],
|
],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -110,11 +113,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, {
|
||||||
@@ -124,8 +127,14 @@ export default {
|
|||||||
},
|
},
|
||||||
pivotOptions() {
|
pivotOptions() {
|
||||||
this.show()
|
this.show()
|
||||||
|
},
|
||||||
|
showViewSettings() {
|
||||||
|
this.handleResize()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
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
|
||||||
@@ -317,4 +326,8 @@ export default {
|
|||||||
.pivot-output:empty {
|
.pivot-output:empty {
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.js-plotly-plot) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
<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"
|
||||||
|
:showViewSettings="showViewSettings"
|
||||||
@loading-image-completed="loadingImage = false"
|
@loading-image-completed="loadingImage = false"
|
||||||
@update="$emit('update')"
|
@update="$emit('update')"
|
||||||
/>
|
/>
|
||||||
@@ -30,11 +32,31 @@
|
|||||||
>
|
>
|
||||||
<pivot-icon />
|
<pivot-icon />
|
||||||
</icon-button>
|
</icon-button>
|
||||||
|
<icon-button
|
||||||
|
:active="mode === 'graph'"
|
||||||
|
tooltip="Switch to graph"
|
||||||
|
tooltipPosition="top-left"
|
||||||
|
@click="mode = 'graph'"
|
||||||
|
>
|
||||||
|
<graph-icon />
|
||||||
|
</icon-button>
|
||||||
|
|
||||||
<div class="side-tool-bar-divider" />
|
<div class="side-tool-bar-divider" />
|
||||||
|
|
||||||
<icon-button
|
<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
|
||||||
|
:disabled="!exportToPngEnabled || loadingImage"
|
||||||
:loading="loadingImage"
|
:loading="loadingImage"
|
||||||
tooltip="Save as PNG image"
|
tooltip="Save as PNG image"
|
||||||
tooltipPosition="top-left"
|
tooltipPosition="top-left"
|
||||||
@@ -44,7 +66,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"
|
||||||
@@ -54,6 +76,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"
|
||||||
@@ -87,10 +110,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import Chart from './Chart/index.vue'
|
import Chart from './Chart/index.vue'
|
||||||
import Pivot from './Pivot/index.vue'
|
import Pivot from './Pivot/index.vue'
|
||||||
|
import Graph from './Graph/index.vue'
|
||||||
import SideToolBar from '../SideToolBar'
|
import SideToolBar from '../SideToolBar'
|
||||||
import IconButton from '@/components/IconButton'
|
import IconButton from '@/components/IconButton'
|
||||||
import ChartIcon from '@/components/svg/chart'
|
import ChartIcon from '@/components/svg/chart'
|
||||||
import PivotIcon from '@/components/svg/pivot'
|
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 HtmlIcon from '@/components/svg/html'
|
||||||
import ExportToSvgIcon from '@/components/svg/exportToSvg'
|
import ExportToSvgIcon from '@/components/svg/exportToSvg'
|
||||||
import PngIcon from '@/components/svg/png'
|
import PngIcon from '@/components/svg/png'
|
||||||
@@ -105,10 +131,13 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
Chart,
|
Chart,
|
||||||
Pivot,
|
Pivot,
|
||||||
|
Graph,
|
||||||
SideToolBar,
|
SideToolBar,
|
||||||
IconButton,
|
IconButton,
|
||||||
ChartIcon,
|
ChartIcon,
|
||||||
PivotIcon,
|
PivotIcon,
|
||||||
|
GraphIcon,
|
||||||
|
SettingsIcon,
|
||||||
ExportToSvgIcon,
|
ExportToSvgIcon,
|
||||||
PngIcon,
|
PngIcon,
|
||||||
HtmlIcon,
|
HtmlIcon,
|
||||||
@@ -124,13 +153,20 @@ 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,
|
||||||
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: {
|
computed: {
|
||||||
@@ -139,9 +175,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: {
|
||||||
@@ -217,6 +254,8 @@ export default {
|
|||||||
events.send(
|
events.send(
|
||||||
this.mode === 'chart' || this.plotlyInPivot
|
this.mode === 'chart' || this.plotlyInPivot
|
||||||
? 'viz_plotly.export'
|
? 'viz_plotly.export'
|
||||||
|
: this.mode === 'graph'
|
||||||
|
? 'viz_graph.export'
|
||||||
: 'viz_pivot.export',
|
: 'viz_pivot.export',
|
||||||
null,
|
null,
|
||||||
eventLabels
|
eventLabels
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ export default {
|
|||||||
border-left: 1px solid var(--color-border-light);
|
border-left: 1px solid var(--color-border-light);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
.side-tool-bar-divider {
|
.side-tool-bar-divider {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export default {
|
|||||||
) {
|
) {
|
||||||
const stmt = [
|
const stmt = [
|
||||||
'/*',
|
'/*',
|
||||||
' * Your database is empty. In order to start building charts',
|
' * Your database is empty. In order to start building data visualisations',
|
||||||
' * you should create a table and insert data into it.',
|
' * you should create tables and insert data into them.',
|
||||||
' */',
|
' */',
|
||||||
'CREATE TABLE house',
|
'CREATE TABLE house',
|
||||||
'(',
|
'(',
|
||||||
@@ -54,7 +54,20 @@ export default {
|
|||||||
"('Gryffindor', 100),",
|
"('Gryffindor', 100),",
|
||||||
"('Hufflepuff', 90),",
|
"('Hufflepuff', 90),",
|
||||||
"('Ravenclaw', 95),",
|
"('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')
|
].join('\n')
|
||||||
|
|
||||||
const tabId = await this.$store.dispatch('addTab', { query: stmt })
|
const tabId = await this.$store.dispatch('addTab', { query: stmt })
|
||||||
|
|||||||
Reference in New Issue
Block a user