Add functional time-series chart

This commit is contained in:
rijkvanzanten
2021-05-26 22:55:30 -04:00
parent 3d45c7215f
commit aa9157778b
10 changed files with 405 additions and 80 deletions

View File

@@ -13,6 +13,7 @@ fields:
- field: width
- field: height
- field: options
special: json
- field: date_created
special: date-created
- field: user_created

View File

@@ -57,6 +57,7 @@
"@vue/cli-service": "^4.5.13",
"@vue/composition-api": "^0.6.7",
"@vue/test-utils": "^1.2.0",
"apexcharts": "^3.26.3",
"axios": "^0.21.1",
"base-64": "^1.0.0",
"codemirror": "^5.61.0",

View File

@@ -428,6 +428,7 @@ no_app_access_copy: This user isn't allowed to use the admin app.
password_reset_sent: We've sent you a secure link to reset your password
password_reset_successful: Password successfully reset
back: Back
filter: Filter
editing_image: Editing Image
square: Square
free: Free
@@ -1183,6 +1184,7 @@ panels:
metric:
name: Metric
description: Show a single value based on a query
field: Field
time_series:
name: Time Series
description: Render a line chart based on values over time

View File

@@ -43,7 +43,7 @@
<script lang="ts">
import { getPanels } from '@/panels';
import { Panel } from '@/types';
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
import { defineComponent, PropType, computed, ref, reactive } from '@vue/composition-api';
import { throttle } from 'lodash';
export default defineComponent({
@@ -67,12 +67,22 @@ export default defineComponent({
});
});
/**
* When drag-n-dropping for positiniong/resizing, we're
*/
const editedPosition = reactive<Partial<Panel>>({
position_x: undefined,
position_y: undefined,
width: undefined,
height: undefined,
});
const positioning = computed(() => {
return {
'--pos-x': props.panel.position_x,
'--pos-y': props.panel.position_y,
'--width': props.panel.width,
'--height': props.panel.height,
'--pos-x': editedPosition.position_x ?? props.panel.position_x,
'--pos-y': editedPosition.position_y ?? props.panel.position_y,
'--width': editedPosition.width ?? props.panel.width,
'--height': editedPosition.height ?? props.panel.height,
};
});
@@ -119,47 +129,39 @@ export default defineComponent({
const gridDeltaY = Math.round(pointerDeltaY / 20);
if (operation === 'move') {
const edits = {
position_x: panelStartPosX + gridDeltaX,
position_y: panelStartPosY + gridDeltaY,
};
editedPosition.position_x = panelStartPosX + gridDeltaX;
editedPosition.position_y = panelStartPosY + gridDeltaY;
if (edits.position_x < 1) edits.position_x = 1;
if (edits.position_y < 1) edits.position_y = 1;
emit('update', edits);
if (editedPosition.position_x < 1) editedPosition.position_x = 1;
if (editedPosition.position_y < 1) editedPosition.position_y = 1;
} else {
let edits: Partial<Panel> = {};
if (operation.includes('top')) {
edits.height = panelStartHeight - gridDeltaY;
edits.position_y = panelStartPosY + gridDeltaY;
editedPosition.height = panelStartHeight - gridDeltaY;
editedPosition.position_y = panelStartPosY + gridDeltaY;
}
if (operation.includes('right')) {
edits.width = panelStartWidth + gridDeltaX;
editedPosition.width = panelStartWidth + gridDeltaX;
}
if (operation.includes('bottom')) {
edits.height = panelStartHeight + gridDeltaY;
editedPosition.height = panelStartHeight + gridDeltaY;
}
if (operation.includes('left')) {
edits.width = panelStartWidth - gridDeltaX;
edits.position_x = panelStartPosX + gridDeltaX;
editedPosition.width = panelStartWidth - gridDeltaX;
editedPosition.position_x = panelStartPosX + gridDeltaX;
}
const minWidth = panelTypeInfo.value?.minWidth || 6;
const minHeight = panelTypeInfo.value?.minHeight || 6;
if (edits.position_x && edits.position_x < 1) edits.position_x = 1;
if (edits.position_y && edits.position_y < 1) edits.position_y = 1;
if (edits.width && edits.width < minWidth) edits.width = minWidth;
if (edits.height && edits.height < minHeight) edits.height = minHeight;
return emit('update', edits);
if (editedPosition.position_x && editedPosition.position_x < 1) editedPosition.position_x = 1;
if (editedPosition.position_y && editedPosition.position_y < 1) editedPosition.position_y = 1;
if (editedPosition.width && editedPosition.width < minWidth) editedPosition.width = minWidth;
if (editedPosition.height && editedPosition.height < minHeight) editedPosition.height = minHeight;
}
}, 50);
}, 20);
return { dragging, onPointerDown, onPointerUp, onPointerMove };
@@ -188,6 +190,8 @@ export default defineComponent({
dragging.value = false;
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointermove', onPointerMove);
emit('update', editedPosition);
}
}
},
@@ -230,6 +234,20 @@ export default defineComponent({
box-shadow: 0 0 0 1px var(--primary);
}
.panel-content {
position: relative;
width: 100%;
height: 100%;
}
.panel-content.has-header {
height: calc(100% - 48px);
}
.panel.editing .panel-content {
pointer-events: none;
}
.header {
display: flex;
align-items: center;
@@ -260,16 +278,6 @@ export default defineComponent({
--v-icon-color-hover: var(--foreground-normal);
}
.panel-content {
position: relative;
width: 100%;
height: 100%;
}
.panel-content.has-header {
height: calc(100% - 48px);
}
.edit-actions {
position: absolute;
top: 0;
@@ -282,6 +290,7 @@ export default defineComponent({
.resize-handlers div {
position: absolute;
z-index: 2;
}
.resize-handlers .top {

View File

@@ -131,8 +131,8 @@ export default defineComponent({
if (editMode.value === true) {
return {
width: (furthestPanelX.position_x! + furthestPanelX.width! + 3) * 20 + 'px',
height: (furthestPanelY.position_y! + furthestPanelY.height! + 3) * 20 + 'px',
width: (furthestPanelX.position_x! + furthestPanelX.width! + 25) * 20 + 'px',
height: (furthestPanelY.position_y! + furthestPanelY.height! + 25) * 20 + 'px',
};
}
@@ -167,11 +167,7 @@ export default defineComponent({
if (stagedPanels.value.some((panel) => panel.id === key)) {
stagedPanels.value = stagedPanels.value.map((panel) => {
if (panel.id === key) {
return {
id: key,
...panel,
...edits,
};
return merge({ id: key }, panel, edits);
}
return panel;

View File

@@ -9,9 +9,73 @@ export default definePanel({
component: PanelMetric,
options: [
{
field: 'all',
name: 'All Options (Debug)',
field: 'collection',
type: 'string',
name: '$t:collection',
meta: {
interface: 'system-collection',
width: 'half',
},
},
{
field: 'field',
type: 'string',
name: '$t:panels.metric.field',
meta: {
interface: 'system-field',
options: {
collectionField: 'collection',
typeAllowList: ['integer', 'bigInteger', 'float', 'decimal'],
},
width: 'half',
},
},
{
field: 'function',
type: 'string',
name: '$t:aggregate_function',
meta: {
width: 'half',
interface: 'select-dropdown',
options: {
choices: [
{
text: 'Average',
value: 'avg',
},
{
text: 'Sum',
value: 'sum',
},
{
text: 'Minimum',
value: 'min',
},
{
text: 'Maximum',
value: 'max',
},
{
text: 'Count',
value: 'count',
},
],
},
},
},
{
field: 'limit',
type: 'integer',
name: '$t:limit',
meta: {
interface: 'input',
width: 'half',
},
},
{
field: 'filter',
type: 'json',
name: '$t:filter',
meta: {
interface: 'code',
options: {

View File

@@ -29,18 +29,18 @@ export default defineComponent({
loading.value = true;
try {
const res = await api.get(`/items/${props.options.all.collection}`, {
const res = await api.get(`/items/${props.options.collection}`, {
params: {
aggregate: {
[props.options.all.function]: {
[props.options.all.field]: 'result',
[props.options.function]: {
[props.options.field]: 'result',
},
},
filter: props.options.all.filter,
filter: props.options.filter,
},
});
metric.value = Number(res.data.data[0].result).toFixed(props.options.all.decimals || 2);
metric.value = Number(res.data.data[0].result).toFixed(props.options.decimals || 2);
} catch (err) {
// oh no
} finally {
@@ -57,7 +57,7 @@ export default defineComponent({
align-items: center;
justify-content: center;
width: 100%;
height: calc(100% - 24px);
height: 100%;
font-weight: 800;
font-size: 42px;
line-height: 52px;

View File

@@ -3,9 +3,9 @@
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watch, onMounted } from '@vue/composition-api';
import { defineComponent, PropType, ref, watch, onMounted, onUnmounted } from '@vue/composition-api';
import api from '@/api';
import { Chart } from 'frappe-charts/src/js/charts/AxisChart';
import ApexCharts from 'apexcharts';
type TimeSeriesOptions = {
collection: string;
@@ -31,27 +31,80 @@ export default defineComponent({
const loading = ref(false);
const error = ref();
const chartEl = ref();
const chart = ref();
const chart = ref<ApexCharts>();
watch(props.options, fetchData);
watch(props.options, fetchData, { deep: true });
fetchData();
onMounted(() => {
chart.value = new Chart(chartEl.value, {
data: getChartData(),
type: 'line',
height: props.height * 20,
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
chart.value = new ApexCharts(chartEl.value, {
colors: ['var(--primary)'],
chart: {
type: 'area',
height: '100%',
toolbar: {
show: false,
},
selection: {
enabled: false,
},
zoom: {
enabled: false,
},
fontFamily: 'var(--family-sans-serif)',
foreColor: 'var(--foreground-subdued)',
},
lineOptions: {
regionFill: true,
spline: true,
hideDots: true,
series: [],
stroke: {
curve: 'smooth',
},
fill: {
type: 'gradient',
gradient: {
colorStops: [
[
{
offset: 0,
color: 'var(--primary-alt)',
opacity: 1,
},
{
offset: 100,
color: 'var(--primary-alt)',
opacity: 0,
},
],
],
},
},
dataLabels: {
enabled: false,
},
tooltip: {
marker: {
show: false,
},
},
xaxis: {
type: 'datetime',
tooltip: {
enabled: false,
},
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
},
});
chart.value.render();
});
onUnmounted(() => {
chart.value?.destroy();
});
return { chartEl, metrics, loading, error };
@@ -66,29 +119,32 @@ export default defineComponent({
group: props.options.dateField,
aggregate: {
[props.options.function]: {
[props.options.valueField]: 'value',
[props.options.valueField]: props.options.valueField,
},
},
},
});
metrics.value = results.data.data;
chart.value?.update(getChartData());
chart.value?.updateOptions({
xaxis: {
categories: metrics.value.map((metric) => metric[props.options.dateField]),
},
});
chart.value?.updateSeries([
{
name: props.options.collection,
data: metrics.value.map((metric) => metric[props.options.valueField]),
},
]);
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
function getChartData() {
return {
labels: metrics.value.map((metric) => metric[props.options.dateField]),
datasets: [
{ name: props.options.collection, values: metrics.value.map((metric) => Number(metric.value).toFixed(2)) },
],
};
}
},
});
</script>

View File

@@ -1,6 +1,6 @@
@import './mixins/form-grid.scss';
* {
*:not(svg *) {
box-sizing: border-box;
margin: 0;
color: inherit;

196
package-lock.json generated
View File

@@ -304,6 +304,7 @@
"@vue/cli-service": "^4.5.13",
"@vue/composition-api": "^0.6.7",
"@vue/test-utils": "^1.2.0",
"apexcharts": "^3.26.3",
"axios": "^0.21.1",
"base-64": "^1.0.0",
"codemirror": "^5.61.0",
@@ -12228,6 +12229,23 @@
"node": ">= 8"
}
},
"node_modules/apexcharts": {
"version": "3.26.3",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.26.3.tgz",
"integrity": "sha512-zbP7RBBV2CGffoVMIuTCUG64YbEUzV8IIT7iNVLMtY/OAVXTjPksDxSqKIniTvgJoscKe6sx4P56qDpBSU19VA==",
"dev": true,
"dependencies": {
"svg.draggable.js": "^2.2.2",
"svg.easing.js": "^2.0.0",
"svg.filter.js": "^2.0.2",
"svg.pathmorphing.js": "^0.1.3",
"svg.resize.js": "^1.4.3",
"svg.select.js": "^3.0.1"
},
"funding": {
"url": "https://github.com/apexcharts/apexcharts.js?sponsor=1"
}
},
"node_modules/apisauce": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/apisauce/-/apisauce-2.1.1.tgz",
@@ -43171,6 +43189,97 @@
"integrity": "sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=",
"dev": true
},
"node_modules/svg.draggable.js": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
"integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==",
"dev": true,
"dependencies": {
"svg.js": "^2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.easing.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz",
"integrity": "sha1-iqmUawqOJ4V6XEChDrpAkeVpHxI=",
"dev": true,
"dependencies": {
"svg.js": ">=2.3.x"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.filter.js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz",
"integrity": "sha1-kQCOFROJ3ZIwd5/L5uLJo2LRwgM=",
"dev": true,
"dependencies": {
"svg.js": "^2.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.js": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz",
"integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==",
"dev": true
},
"node_modules/svg.pathmorphing.js": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz",
"integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==",
"dev": true,
"dependencies": {
"svg.js": "^2.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.resize.js": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz",
"integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==",
"dev": true,
"dependencies": {
"svg.js": "^2.6.5",
"svg.select.js": "^2.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.resize.js/node_modules/svg.select.js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz",
"integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==",
"dev": true,
"dependencies": {
"svg.js": "^2.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svg.select.js": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz",
"integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==",
"dev": true,
"dependencies": {
"svg.js": "^2.6.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/svgo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz",
@@ -55374,6 +55483,7 @@
"@vue/cli-service": "^4.5.13",
"@vue/composition-api": "^0.6.7",
"@vue/test-utils": "^1.2.0",
"apexcharts": "*",
"axios": "^0.21.1",
"base-64": "^1.0.0",
"codemirror": "^5.61.0",
@@ -65169,6 +65279,20 @@
"picomatch": "^2.0.4"
}
},
"apexcharts": {
"version": "3.26.3",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.26.3.tgz",
"integrity": "sha512-zbP7RBBV2CGffoVMIuTCUG64YbEUzV8IIT7iNVLMtY/OAVXTjPksDxSqKIniTvgJoscKe6sx4P56qDpBSU19VA==",
"dev": true,
"requires": {
"svg.draggable.js": "^2.2.2",
"svg.easing.js": "^2.0.0",
"svg.filter.js": "^2.0.2",
"svg.pathmorphing.js": "^0.1.3",
"svg.resize.js": "^1.4.3",
"svg.select.js": "^3.0.1"
}
},
"apisauce": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/apisauce/-/apisauce-2.1.1.tgz",
@@ -90059,6 +90183,78 @@
"integrity": "sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=",
"dev": true
},
"svg.draggable.js": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz",
"integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==",
"dev": true,
"requires": {
"svg.js": "^2.0.1"
}
},
"svg.easing.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz",
"integrity": "sha1-iqmUawqOJ4V6XEChDrpAkeVpHxI=",
"dev": true,
"requires": {
"svg.js": ">=2.3.x"
}
},
"svg.filter.js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz",
"integrity": "sha1-kQCOFROJ3ZIwd5/L5uLJo2LRwgM=",
"dev": true,
"requires": {
"svg.js": "^2.2.5"
}
},
"svg.js": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz",
"integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==",
"dev": true
},
"svg.pathmorphing.js": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz",
"integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==",
"dev": true,
"requires": {
"svg.js": "^2.4.0"
}
},
"svg.resize.js": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz",
"integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==",
"dev": true,
"requires": {
"svg.js": "^2.6.5",
"svg.select.js": "^2.1.2"
},
"dependencies": {
"svg.select.js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz",
"integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==",
"dev": true,
"requires": {
"svg.js": "^2.2.5"
}
}
}
},
"svg.select.js": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz",
"integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==",
"dev": true,
"requires": {
"svg.js": "^2.6.5"
}
},
"svgo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz",