mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Add functional time-series chart
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import './mixins/form-grid.scss';
|
||||
|
||||
* {
|
||||
*:not(svg *) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
|
||||
Reference in New Issue
Block a user