From 395535d6a890a6c62395c9d83bd35b84697018f5 Mon Sep 17 00:00:00 2001 From: mwbernard Date: Fri, 24 Nov 2023 10:59:52 -0800 Subject: [PATCH 1/4] Use canvas for homepage map --- vacs-map-app/src/components/MapHomepage.vue | 108 +++++++++++--------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/vacs-map-app/src/components/MapHomepage.vue b/vacs-map-app/src/components/MapHomepage.vue index 72c0c30..7fedc18 100644 --- a/vacs-map-app/src/components/MapHomepage.vue +++ b/vacs-map-app/src/components/MapHomepage.vue @@ -1,36 +1,6 @@ @@ -42,7 +12,6 @@ import { useFiltersStore } from '@/stores/filters' import { useCropYieldsStore } from '@/stores/cropYields' import { useGridStore } from '@/stores/grid' import * as d3 from 'd3' -import { geoChamberlinAfrica } from 'd3-geo-projection' import { divergingScheme } from '../utils/colors' const filtersStore = useFiltersStore() @@ -54,6 +23,9 @@ const { data: gridData } = storeToRefs(gridStore) const { availableCrops, availableModels } = storeToRefs(filtersStore) const wrapperRef = ref(null); +const canvasRef = ref(null); +const context = ref(null); + const inset = -20; const width = ref(0); const height = ref(0); @@ -67,6 +39,10 @@ const switchCrop = ref(false); useResizeObserver(wrapperRef, ([entry]) => { width.value = entry.contentRect.width; height.value = entry.contentRect.height; + + canvasRef.value.width = width.value; + canvasRef.value.height = height.value; + draw(); }); const futureScenarios = computed(() => availableModels.value.filter(d => d.startsWith('future'))); @@ -94,18 +70,21 @@ const outline = ({type: "Sphere"}); // this handles the projection, with translation and scale based on window size (responsive) const projection = computed(() => { return d3.geoOrthographic() - .rotate([-15,-3]) + .rotate([-18,-3]) .fitExtent([[inset, inset], [width.value - inset, height.value - inset]], outline) }) + const gridCells = computed(() => { if (!gridData.value || !cropYieldsData.value) return return gridData.value.map((cell, i) => { + const val = cropYieldsData.value[i][selectedColumn.value]; return { id: cell.id, - val: cropYieldsData.value[i][selectedColumn.value], + val, x: projection.value([cell.X, cell.Y])[0], - y: projection.value([cell.X, cell.Y])[1] + y: projection.value([cell.X, cell.Y])[1], + fill: getCellColor(val) } }) }) @@ -141,6 +120,43 @@ const updateGrid = () => { } switchCrop.value = true; } + draw(); +} + +const draw = () => { + if (!context.value) return; + + context.value.save(); + //clear old canvas + context.value.clearRect(0, 0, width.value, height.value); + + //draw 'globe' + const radius = Math.min(width.value, height.value)/2 - 90; + const gradient = context.value.createRadialGradient( + width.value/2, height.value/2, 0, + width.value/2, height.value/2, radius + ); + gradient.addColorStop(0.05, '#3B4650'); + gradient.addColorStop(0.99, '#272E34'); + gradient.addColorStop(1, '#17191b'); + + context.value.fillStyle = gradient; + context.value.fillRect( + width.value/2 - radius, + height.value/2 - radius, + 2*radius, + 2*radius + ); + + //draw grid dots + const cellRadius = 1; + gridCells.value?.forEach(cell => { + context.value.beginPath(); + context.value.arc(cell.x, cell.y, cellRadius, 0, Math.PI * 2, false); + context.value.fillStyle = cell.fill; + context.value.fill(); + }) + context.value.restore(); } onMounted(() => { @@ -150,6 +166,13 @@ onMounted(() => { width.value = wrapperRef.value.clientWidth height.value = wrapperRef.value.clientHeight + canvasRef.value.width = width.value; + canvasRef.value.height = height.value; + + context.value = canvasRef.value?.getContext('2d') + + draw(); + // call update grid every x milliseconds timer.value = setInterval(() => { updateGrid() @@ -162,21 +185,10 @@ onBeforeUnmount(() => { From a9f0b671f0a5a51432159eb1f8b1dc0d051a7bd5 Mon Sep 17 00:00:00 2001 From: mwbernard Date: Fri, 24 Nov 2023 11:16:37 -0800 Subject: [PATCH 2/4] Use canvas for distribution plots --- .../src/components/DistributionPlot.vue | 69 ++++++++++++------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/vacs-map-app/src/components/DistributionPlot.vue b/vacs-map-app/src/components/DistributionPlot.vue index 393f31b..f26e053 100644 --- a/vacs-map-app/src/components/DistributionPlot.vue +++ b/vacs-map-app/src/components/DistributionPlot.vue @@ -1,25 +1,13 @@ From ff4e03dcb2a21a892b4a17b8111b8852c5b91845 Mon Sep 17 00:00:00 2001 From: mwbernard Date: Mon, 27 Nov 2023 08:40:55 -0800 Subject: [PATCH 3/4] Make scenarios horizontal --- vacs-map-app/src/components/ExploreSidebar.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vacs-map-app/src/components/ExploreSidebar.vue b/vacs-map-app/src/components/ExploreSidebar.vue index b893102..c85e414 100644 --- a/vacs-map-app/src/components/ExploreSidebar.vue +++ b/vacs-map-app/src/components/ExploreSidebar.vue @@ -83,7 +83,7 @@ const getCropsByGroup = (group) => { width: 100%; height: 40%; display: flex; - flex-direction: row; + flex-direction: column; gap: 1rem; } From 87d50a3d0731fb1cb71480055a36fc409f3c5d9d Mon Sep 17 00:00:00 2001 From: mwbernard Date: Mon, 27 Nov 2023 09:40:31 -0800 Subject: [PATCH 4/4] Update distribution plot axis --- .../src/components/DistributionPlot.vue | 98 +++++++++++++------ 1 file changed, 68 insertions(+), 30 deletions(-) diff --git a/vacs-map-app/src/components/DistributionPlot.vue b/vacs-map-app/src/components/DistributionPlot.vue index c54d7e7..dbab6f2 100644 --- a/vacs-map-app/src/components/DistributionPlot.vue +++ b/vacs-map-app/src/components/DistributionPlot.vue @@ -41,28 +41,13 @@ useResizeObserver(wrapperRef, ([entry]) => { draw(); }) -const margin = computed(() => { - return height.value * 0.2 -}) const columnName = computed(() => { return [selectedMetric.value, selectedCrop.value, scenario.value].join('_') }) -const metaExtent = computed(() => { - // want to get extent across all scenarios and included crops so that comparisons are more useful - const extents = [] - availableModels.value.forEach((s) => { - availableCrops.value.forEach((c) => { - const column = [selectedMetric.value, c, s].join('_') - extents.push(cropYieldsStore.getExtent(column)) - }) - }) - return [d3.min(extents.map((d) => d[0])), d3.min(extents.map((d) => d[1]))] -}) - -const colorExtent = computed(() => { - // want to get extent across all scenarios and included crops so that comparisons are more useful +const cropExtent = computed(() => { + // want to get extent across all scenarios for selected crop const extents = [] availableModels.value.forEach((s) => { const column = [selectedMetric.value, selectedCrop.value, s].join('_') @@ -78,35 +63,79 @@ const gridCells = computed(() => { return { id: row.id, val, - y: yScale.value(val), + x: xScale.value(val), fill: getCellColor(val) } - }) + }).filter(d => !!d.val) }) -const yScale = computed(() => { +const xScale = computed(() => { return d3 .scaleLinear() - .domain(metaExtent.value) - .range([height.value - margin.value, margin.value]) - // .clamp(true); + // keep 0 values centered on spectrum + .domain([cropExtent.value[0], 0, cropExtent.value[1]]) + .range([0, width.value/2, width.value]) + // .clamp(true); }) -// const xScale = computed(() => { -// return d3.scaleLinear().domain([0, 1]).range([0, width.value]) -// }) - const getCellColor = (value) => { if (!value) return 'transparent' const scale = d3 .scaleLinear() - .domain([colorExtent.value[0], 0, colorExtent.value[1]]) + .domain([cropExtent.value[0], 0, cropExtent.value[1]]) .range([divergingScheme.min, divergingScheme.center, divergingScheme.max]) .clamp(true) return scale(value) } +// from D3 beeswarm example +const dodge = (data, {radius = 1, x = d => d} = {}) => { + const radius2 = radius ** 2; + const circles = data.map(d => ({x: x(d), data: d})).sort((a, b) => a.x - b.x); + const epsilon = 1e-3; + let head = null, tail = null; + + // Returns true if circle ⟨x,y⟩ intersects with any circle in the queue. + function intersects(x, y) { + let a = head; + while (a) { + if (radius2 - epsilon > (a.x - x) ** 2 + (a.y - y) ** 2) { + return true; + } + a = a.next; + } + return false; + } + + // Place each circle sequentially. + for (const b of circles) { + + // Remove circles from the queue that can’t intersect the new circle b. + while (head && head.x < b.x - radius2) head = head.next; + + // Choose the minimum non-intersecting tangent. + if (intersects(b.x, b.y = 0)) { + let a = head; + b.y = Infinity; + do { + let y1 = a.y + Math.sqrt(radius2 - (a.x - b.x) ** 2); + let y2 = a.y - Math.sqrt(radius2 - (a.x - b.x) ** 2); + if (Math.abs(y1) < Math.abs(b.y) && !intersects(b.x, y1)) b.y = y1; + if (Math.abs(y2) < Math.abs(b.y) && !intersects(b.x, y2)) b.y = y2; + a = a.next; + } while (a); + } + + // Add b to the queue. + b.next = null; + if (head === null) head = tail = b; + else tail = tail.next = b; + } + + return circles; +} + const draw = () => { if (!context.value) return; @@ -114,11 +143,20 @@ const draw = () => { //clear old canvas context.value.clearRect(0, 0, width.value, height.value); - //draw grid dots gridCells.value?.forEach(cell => { context.value.fillStyle = cell.fill; - context.value.fillRect(0, cell.y, width.value, 0.1); + context.value.fillRect(cell.x, 0, 0.1, height.value); }) + + // draw dots as swarm plot -- seems too slow + // const grid = dodge(gridCells.value, {radius: 1, x: d => d.x}); + + // //draw grid dots + // grid.forEach(cell => { + // context.value.fillStyle = cell.data.fill; + // context.value.fillRect(cell.x, height.value*.3 + cell.y, 2, 2); + // }) + context.value.restore(); }