diff --git a/src/App.vue b/src/App.vue index 86359a7..a092f76 100644 --- a/src/App.vue +++ b/src/App.vue @@ -28,6 +28,7 @@ {{ $t('menuVisualizationMap') }} {{ $t('menuDataExport') }} + {{ $t('menuDataStatistics') }} {{ $t('menuSettings') }} @@ -124,7 +125,7 @@ import { VuePlausible } from 'vue-plausible' import Vue from 'vue' import { axiosCall, getServerSettings } from '@/plugins/api' -import { BIconInfoCircle, BIconFlag, BIconHouse, BIconGear, BIconGithub, BIconGlobe2, BIconTwitter, BIconUiChecksGrid, BIconGraphUp, BIconPinMapFill, BIconGridFill, BIconBarChartSteps, BIconEasel, BIconMoon, BIconSun, BIconCloudDownload } from 'bootstrap-vue' +import { BIconInfoCircle, BIconFlag, BIconHouse, BIconGear, BIconGithub, BIconGlobe2, BIconClipboardData, BIconTwitter, BIconUiChecksGrid, BIconGraphUp, BIconPinMapFill, BIconGridFill, BIconBarChartSteps, BIconEasel, BIconMoon, BIconSun, BIconCloudDownload } from 'bootstrap-vue' import { getId } from '@/plugins/id' import { gridScoreVersion } from '@/plugins/constants' import { isOffline } from '@/plugins/misc' @@ -150,6 +151,7 @@ export default { BIconPinMapFill, BIconSun, BIconCloudDownload, + BIconClipboardData, BIconGithub, BIconGlobe2, BIconTwitter, diff --git a/src/components/charts/DataCalendarHeatmapChart.vue b/src/components/charts/DataCalendarHeatmapChart.vue new file mode 100644 index 0000000..94e50d8 --- /dev/null +++ b/src/components/charts/DataCalendarHeatmapChart.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/src/plugins/color.js b/src/plugins/color.js new file mode 100644 index 0000000..1d98789 --- /dev/null +++ b/src/plugins/color.js @@ -0,0 +1,52 @@ +/** + * Converts a HEX value into an RGB object + * @param {String} hex The hex color + */ +const hexToRgb = (hex) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null +} + +const rgbToHex = (c) => `#${((1 << 24) + (c.r << 16) + (c.g << 8) + c.b).toString(16).slice(1)}` + +/** + * Determines the best text color (either white or black) given the background color + * @param {String} backgroundColor The background color in HEX + */ +const getHighContrastTextColor = (backgroundColor) => { + if (backgroundColor) { + const rgb = hexToRgb(backgroundColor) + if (!rgb) { + return 'black' + } + const o = Math.round(((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000) + return (o > 125) ? 'black' : 'white' + } else { + return 'black' + } +} + +const shadeColor = (hex, percent) => { + const rgb = hexToRgb(hex) + + let r = parseInt(rgb.r * (100 + percent) / 100) + let g = parseInt(rgb.g * (100 + percent) / 100) + let b = parseInt(rgb.b * (100 + percent) / 100) + + r = (r < 255) ? r : 255 + g = (g < 255) ? g : 255 + b = (b < 255) ? b : 255 + + r = Math.round(r) + g = Math.round(g) + b = Math.round(b) + + return rgbToHex({ r, g, b }) +} + +export { + getHighContrastTextColor, + shadeColor, + hexToRgb, + rgbToHex +} diff --git a/src/plugins/i18n/de_DE.json b/src/plugins/i18n/de_DE.json index 1f40302..835761f 100644 --- a/src/plugins/i18n/de_DE.json +++ b/src/plugins/i18n/de_DE.json @@ -316,6 +316,7 @@ "menuDataEntry": "Dateneingabe", "menuDataExport": "Exportieren", "menuDataVisualization": "Datenvisualisierung", + "menuDataStatistics": "Datenstatistiken", "menuToggleDarkMode": "Dark-Mode umschalten", "menuLocale": "Sprache", "menuAbout": "Über", @@ -792,5 +793,25 @@ "widgetGuideOrderTabZigzag": "Zickzack", "widgetGuideOrderTabSnake": "Schlangenförmig", "widgetTraitInputPreviousMeasures": "Vorheriger Wert ({date}): {values}", - "widgetTraitInputCurrentMeasures": "Aktueller Wert ({date}): {values}" + "widgetTraitInputCurrentMeasures": "Aktueller Wert ({date}): {values}", + "widgetTrialDataStatsPlots": "{count} Beete bewertet", + "widgetTrialDataStatsTraits": "{count} Merkmale bewertet", + "widgetTrialDataStatsComments": "{count} Kommentare aufgezeichnet", + "widgetTrialDataStatsEvents": "{count} Ereignisse aufgezeichnet", + "widgetTrialDataStatsMeasurements": "{count} Messungen aufgezeichnet", + "widgetTrialDataStatsArea": "{area} {unit} abgedeckt", + "widgetTrialDataStatsAreaUnknown": "Unbekannte Fläche", + "areaUnitMeter": "Quadratmeter", + "areaUnitKilometer": "Quadratkilometer", + "areaUnitHectare": "Hektar", + "areaUnitAcre": "Acker", + "areaUnitMiles": "Quadratmeilen", + "areaUnitFoot": "Quadratfuß", + "areaUnitYard": "Quadratyard", + "pageDataStatisticsTitle": "Übersichtsstatistiken", + "pageDataStatisticsText": "Diese Seite zeigt eine statistische Sicht auf die Versuche auf diesem Gerät. Wähle die Versuche zum darstellen unten aus.", + "formLabelDataStatsTrial": "Versuch auswählen", + "formLabelDataStatsAreaUnit": "Flächeneinheit auswählen", + "widgetChartHeatmapAxisTitleMonth": "Monat", + "widgetChartHeatmapAxisTitleDay": "Tag" } diff --git a/src/plugins/i18n/en_GB.json b/src/plugins/i18n/en_GB.json index b261b6f..f6ba1eb 100644 --- a/src/plugins/i18n/en_GB.json +++ b/src/plugins/i18n/en_GB.json @@ -325,6 +325,7 @@ "menuDataEntry": "Data entry", "menuDataExport": "Export", "menuDataVisualization": "Data visualizations", + "menuDataStatistics": "Data statistics", "menuToggleDarkMode": "Toggle dark mode", "menuLocale": "Locale", "menuAbout": "About", @@ -803,5 +804,25 @@ "widgetGuideOrderTabZigzag": "Zig-zag", "widgetGuideOrderTabSnake": "Snake", "widgetTraitInputPreviousMeasures": "Previous measurement ({date}): {values}", - "widgetTraitInputCurrentMeasures": "Current measurement ({date}): {values}" + "widgetTraitInputCurrentMeasures": "Current measurement ({date}): {values}", + "widgetTrialDataStatsPlots": "{count} plots scored", + "widgetTrialDataStatsTraits": "{count} traits scored", + "widgetTrialDataStatsComments": "{count} comments recorded", + "widgetTrialDataStatsEvents": "{count} events recorded", + "widgetTrialDataStatsMeasurements": "{count} measurements taken", + "widgetTrialDataStatsArea": "{area} {unit} covered", + "widgetTrialDataStatsAreaUnknown": "Unknown area", + "areaUnitMeter": "Square metres", + "areaUnitKilometer": "Square kilometres", + "areaUnitHectare": "Hectare", + "areaUnitAcre": "Acres", + "areaUnitMiles": "Square miles", + "areaUnitFoot": "Square feet", + "areaUnitYard": "Square yards", + "pageDataStatisticsTitle": "Overview statistics", + "pageDataStatisticsText": "This page gives you a statistical view onto the trials on your device. Select the trials to visualize below.", + "formLabelDataStatsTrial": "Select trial", + "formLabelDataStatsAreaUnit": "Select area unit", + "widgetChartHeatmapAxisTitleMonth": "Month", + "widgetChartHeatmapAxisTitleDay": "Day" } diff --git a/src/plugins/location.js b/src/plugins/location.js index ff54c26..8326f90 100644 --- a/src/plugins/location.js +++ b/src/plugins/location.js @@ -81,7 +81,7 @@ const trialLayoutToPlots = (corners, rows, cols) => { return result } -const plotInfoToGeoJson = (plotInfo) => { +const plotInfoToGeoJson = (plotInfo, includePoints = true) => { const geoJson = { type: 'FeatureCollection', features: [] @@ -104,7 +104,7 @@ const plotInfoToGeoJson = (plotInfo) => { ]] } }) - } else if (p.center) { + } else if (p.center && includePoints) { geoJson.features.push({ type: 'Feature', geometry: { @@ -188,6 +188,49 @@ const isGeographyValid = (geography) => { return topLeft && topRight && bottomRight && bottomLeft } +const removeMiddle = (a, b, c) => { + const cross = (a.x - b.x) * (c.y - b.y) - (a.y - b.y) * (c.x - b.x) + const dot = (a.x - b.x) * (c.x - b.x) + (a.y - b.y) * (c.y - b.y) + return cross < 0 || (cross === 0 && dot <= 0) +} + +const convexHull = (points) => { + points.sort((a, b) => a.x !== b.x ? a.x - b.x : a.y - b.y) + + const n = points.length + const hull = [] + + for (let i = 0; i < 2 * n; i++) { + const j = i < n ? i : 2 * n - 1 - i + while (hull.length >= 2 && removeMiddle(hull[hull.length - 2], hull[hull.length - 1], points[j])) { + hull.pop() + } + hull.push(points[j]) + } + + hull.pop() + return hull +} + +const toRadians = (angleInDegrees) => (angleInDegrees * Math.PI) / 180 + +const geodesicArea = (latLngs) => { + let area = 0 + const len = latLngs.length + let x1 = latLngs[latLngs.length - 1].lng + let y1 = latLngs[latLngs.length - 1].lat + + for (let i = 0; i < len; i++) { + const x2 = latLngs[i].lng + const y2 = latLngs[i].lat + area += toRadians(x2 - x1) * (2 + Math.sin(toRadians(y1)) + Math.sin(toRadians(y2))) + x1 = x2 + y1 = y2 + } + + return Math.abs((area * 6378137.0 * 6378137.0) / 2.0) +} + export { euclideanSpace, projectToEuclidean, @@ -197,5 +240,7 @@ export { plotInfoToGeoJson, isLocationValid, isGeographyValid, - isGeographyAllNull + isGeographyAllNull, + convexHull, + geodesicArea } diff --git a/src/router/index.js b/src/router/index.js index db81edb..04b663d 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -93,6 +93,10 @@ const routes = [ path: '/visualization/map', name: 'visualization-map', component: () => import('@/views/VizMapView.vue') + }, { + path: '/data-statistics', + name: 'data-statistics', + component: () => import('@/views/DataStatistics.vue') } ] diff --git a/src/views/DataStatistics.vue b/src/views/DataStatistics.vue new file mode 100644 index 0000000..8f67da8 --- /dev/null +++ b/src/views/DataStatistics.vue @@ -0,0 +1,456 @@ + + + + +