diff --git a/app/src/app/components/sidebar/BrushSizeSelector.tsx b/app/src/app/components/sidebar/BrushSizeSelector.tsx index 69d85bd8..e6efa0eb 100644 --- a/app/src/app/components/sidebar/BrushSizeSelector.tsx +++ b/app/src/app/components/sidebar/BrushSizeSelector.tsx @@ -1,6 +1,15 @@ -import { Slider, Flex } from "@radix-ui/themes"; +import { Slider, Flex, Heading, Text } from "@radix-ui/themes"; import { useMapStore } from "../../store/mapStore"; +/** + * BrushSizeSelector + * Note: right now the brush size is an arbitrary value between + * 1 and 100. This is slightly arbitrary. Should we communicate brush size + * differently or not display the brush size? + * + * @description A slider to select the brush size + * @returns {JSX.Element} The component + */ export function BrushSizeSelector() { const brushSize = useMapStore((state) => state.brushSize); const setBrushSize = useMapStore((state) => state.setBrushSize); @@ -11,8 +20,15 @@ export function BrushSizeSelector() { }; return ( - -

Brush Size

+ + + Brush Size + - {brushSize} + + {brushSize} + ); } diff --git a/app/src/app/components/sidebar/ColorPicker.jsx b/app/src/app/components/sidebar/ColorPicker.jsx index 25f7de1f..ba3ef7c8 100644 --- a/app/src/app/components/sidebar/ColorPicker.jsx +++ b/app/src/app/components/sidebar/ColorPicker.jsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { palette, color10 } from "../../constants/colors"; +import { _colorScheme, colorScheme } from "../../constants/colors"; import { Button } from "@radix-ui/themes"; import { styled } from "@stitches/react"; import * as RadioGroup from "@radix-ui/react-radio-group"; @@ -7,15 +7,13 @@ import { blackA } from "@radix-ui/colors"; import { useMapStore } from "../../store/mapStore"; export function ColorPicker() { - const [color, setColor] = useState(null); - const [open, setOpen] = useState(false); const selectedZone = useMapStore((state) => state.selectedZone); const setSelectedZone = useMapStore((state) => state.setSelectedZone); const setZoneAssignments = useMapStore((state) => state.setZoneAssignments); const accumulatedGeoids = useMapStore((state) => state.accumulatedGeoids); const resetAccumulatedBlockPopulations = useMapStore((state) => state.resetAccumulatedBlockPopulations); - const colorArray = color10; + const colorArray = colorScheme; if (!colorArray) return null; const handleRadioChange = (value) => { console.log( diff --git a/app/src/app/components/sidebar/MapModeSelector.jsx b/app/src/app/components/sidebar/MapModeSelector.jsx index 6bee337d..5d6ff113 100644 --- a/app/src/app/components/sidebar/MapModeSelector.jsx +++ b/app/src/app/components/sidebar/MapModeSelector.jsx @@ -15,7 +15,7 @@ export function MapModeSelector() { const activeTools = [ { mode: "pan", disabled: false, label: "Pan", icon: }, { mode: "brush", disabled: false, label: "Brush", icon: }, - { mode: "erase", disabled: true, label: "Erase", icon: }, + { mode: "eraser", disabled: false, label: "Erase", icon: }, ]; const handleRadioChange = (value) => { diff --git a/app/src/app/components/sidebar/Sidebar.tsx b/app/src/app/components/sidebar/Sidebar.tsx index e793a7c2..1c1aed30 100644 --- a/app/src/app/components/sidebar/Sidebar.tsx +++ b/app/src/app/components/sidebar/Sidebar.tsx @@ -24,13 +24,17 @@ export default function SidebarComponent() { - {activeTool === "brush" ? ( + {activeTool === "brush" || activeTool === "eraser" ? (
- {" "}
) : null} + {activeTool === "brush" ? ( +
+ +
+ ) : null} diff --git a/app/src/app/components/sidebar/charts/HorizontalBarChart.tsx b/app/src/app/components/sidebar/charts/HorizontalBarChart.tsx index 0190b950..8c4d609b 100644 --- a/app/src/app/components/sidebar/charts/HorizontalBarChart.tsx +++ b/app/src/app/components/sidebar/charts/HorizontalBarChart.tsx @@ -9,7 +9,7 @@ import { YAxis, Cell, } from "recharts"; -import { color10 } from "@/app/constants/colors"; +import { colorScheme } from "@/app/constants/colors"; type TooltipInput = { active?: boolean; @@ -56,7 +56,8 @@ export const HorizontalBar = () => { { {mapMetrics.data .sort((a, b) => a.zone - b.zone) .map((entry, index) => ( - + ))} diff --git a/app/src/app/constants/colors.ts b/app/src/app/constants/colors.ts index 5c74147b..d4249155 100644 --- a/app/src/app/constants/colors.ts +++ b/app/src/app/constants/colors.ts @@ -27,63 +27,47 @@ import { sky, } from "@radix-ui/colors"; -export const palette = { - colors: { - ...(tomato as object), - ...(red as object), - ...(ruby as object), - ...(crimson as object), - ...(pink as object), - ...(plum as object), - ...(purple as object), - ...(violet as object), - ...(iris as object), - ...(indigo as object), - ...(blue as object), - ...(cyan as object), - ...(teal as object), - ...(jade as object), - ...(green as object), - ...(grass as object), - ...(orange as object), - ...(amber as object), - ...(yellow as object), - ...(gold as object), - ...(brown as object), - ...(bronze as object), - ...(gray as object), - ...(mint as object), - ...(lime as object), - ...(sky as object), - } as { [key: string]: { [key: string]: string } }, -}; - -// bright colors! -export const color10 = [ - tomato.tomato10, - red.red10, - ruby.ruby10, - crimson.crimson10, - pink.pink10, - plum.plum10, - purple.purple10, - violet.violet10, - iris.iris10, - indigo.indigo10, - blue.blue10, - cyan.cyan10, - teal.teal10, - jade.jade10, - green.green10, - grass.grass10, - orange.orange10, - amber.amber10, - yellow.yellow10, - gold.gold10, - brown.brown10, - bronze.bronze10, - gray.gray10, - mint.mint10, - lime.lime10, - sky.sky10, +export const colorScheme = [ + "#0099cd", + "#ffca5d", + "#00cd99", + "#99cd00", + "#cd0099", + "#aa44ef", // lighter, req from San Diego + // Color brewer: + "#8dd3c7", + "#bebada", + "#fb8072", + "#80b1d3", + "#fdb462", + "#b3de69", + "#fccde5", + "#bc80bd", + "#ccebc5", + "#ffed6f", + "#ffffb3", + // other color brewer scheme: + "#a6cee3", + "#1f78b4", + "#b2df8a", + "#33a02c", + "#fb9a99", + "#e31a1c", + "#fdbf6f", + "#ff7f00", + "#cab2d6", + "#6a3d9a", + "#b15928", + // random material design colors: + "#64ffda", + "#00B8D4", + "#A1887F", + "#76FF03", + "#DCE775", + "#B388FF", + "#FF80AB", + "#D81B60", + "#26A69A", + "#FFEA00", + "#6200EA", ]; diff --git a/app/src/app/constants/layers.ts b/app/src/app/constants/layers.ts index 314c2917..508a3a9f 100644 --- a/app/src/app/constants/layers.ts +++ b/app/src/app/constants/layers.ts @@ -7,8 +7,8 @@ import { MutableRefObject } from "react"; import { Map } from "maplibre-gl"; import { getBlocksSource } from "./sources"; import { DocumentObject } from "../utils/api/apiHandlers"; -import { color10 } from "./colors"; import { MapStore, useMapStore } from "../store/mapStore"; +import { colorScheme } from "./colors"; export const BLOCK_SOURCE_ID = "blocks"; export const BLOCK_LAYER_ID = "blocks"; @@ -46,10 +46,14 @@ export const COUNTY_LAYER_IDS: string[] = [ export const LABELS_BREAK_LAYER_ID = "places_subplace"; const colorStyleBaseline: any[] = ["case"]; -export const ZONE_ASSIGNMENT_STYLE_DYNAMIC = color10.reduce((val, color, i) => { - val.push(["==", ["feature-state", "zone"], i + 1], color); // 1-indexed per mapStore.ts - return val; -}, colorStyleBaseline); + +export const ZONE_ASSIGNMENT_STYLE_DYNAMIC = colorScheme.reduce( + (val, color, i) => { + val.push(["==", ["feature-state", "zone"], i + 1], color); // 1-indexed per mapStore.ts + return val; + }, + colorStyleBaseline +); ZONE_ASSIGNMENT_STYLE_DYNAMIC.push("#cecece"); // cast the above as an ExpressionSpecification diff --git a/app/src/app/constants/types.ts b/app/src/app/constants/types.ts index 99c8330b..c1b70628 100644 --- a/app/src/app/constants/types.ts +++ b/app/src/app/constants/types.ts @@ -1,6 +1,6 @@ import type { MapOptions, MapLibreEvent } from "maplibre-gl"; -export type Zone = number; +export type Zone = number | null; export type GEOID = string; diff --git a/app/src/app/store/mapStore.ts b/app/src/app/store/mapStore.ts index 2eacd4f6..b8863852 100644 --- a/app/src/app/store/mapStore.ts +++ b/app/src/app/store/mapStore.ts @@ -63,9 +63,7 @@ export interface MapStore { setSelectedZone: (zone: Zone) => void; accumulatedBlockPopulations: Map; resetAccumulatedBlockPopulations: () => void; - // TODO: Add parent/child status to zoneAssignments - // Probably, something like Map - zoneAssignments: Map; // geoid -> zone + zoneAssignments: Map; // geoid -> zone setZoneAssignments: (zone: Zone, gdbPaths: Set) => void; loadZoneAssignments: (assigments: Assignment[]) => void; resetZoneAssignments: () => void; diff --git a/app/src/app/utils/events/handlers.ts b/app/src/app/utils/events/handlers.ts index e234521e..af967610 100644 --- a/app/src/app/utils/events/handlers.ts +++ b/app/src/app/utils/events/handlers.ts @@ -2,6 +2,7 @@ import { BLOCK_SOURCE_ID } from "@/app/constants/layers"; import { MutableRefObject } from "react"; import { Map, MapGeoJSONFeature } from "maplibre-gl"; import { debounce } from "lodash"; +import { Zone } from "@/app/constants/types"; import { MapStore } from "@/app/store/mapStore"; /** @@ -11,7 +12,7 @@ import { MapStore } from "@/app/store/mapStore"; * @returns void - but updates the zoneAssignments and zonePopulations in the store */ const debouncedSetZoneAssignments = debounce( - (mapStoreRef: MapStore, selectedZone: number, geoids: Set) => { + (mapStoreRef: MapStore, selectedZone: Zone, geoids: Set) => { mapStoreRef.setZoneAssignments(selectedZone, geoids); const accumulatedBlockPopulations = mapStoreRef.accumulatedBlockPopulations; @@ -41,6 +42,16 @@ export const SelectMapFeatures = ( map: MutableRefObject, mapStoreRef: MapStore, ) => { + let { + accumulatedGeoids, + accumulatedBlockPopulations, + activeTool, + selectedZone, + } = mapStoreRef; + if (activeTool === "eraser") { + selectedZone = null; + } + features?.forEach((feature) => { map.current?.setFeatureState( { @@ -48,14 +59,14 @@ export const SelectMapFeatures = ( id: feature?.id ?? undefined, sourceLayer: feature.sourceLayer, }, - { selected: true, zone: mapStoreRef.selectedZone }, + { selected: true, zone: selectedZone }, ); }); if (features?.length) { features.forEach((feature) => { - mapStoreRef.accumulatedGeoids.add(feature.properties?.path); + accumulatedGeoids.add(feature.properties?.path); - mapStoreRef.accumulatedBlockPopulations.set( + accumulatedBlockPopulations.set( feature.properties?.path, feature.properties?.total_pop, ); @@ -80,7 +91,7 @@ export const SelectZoneAssignmentFeatures = (mapStoreRef: MapStore) => { if (accumulatedGeoids?.size) { debouncedSetZoneAssignments( mapStoreRef, - mapStoreRef.selectedZone, + mapStoreRef.activeTool === "brush" ? mapStoreRef.selectedZone : null, mapStoreRef.accumulatedGeoids, ); } diff --git a/app/src/app/utils/events/mapEvents.ts b/app/src/app/utils/events/mapEvents.ts index 5755d12c..48dd2793 100644 --- a/app/src/app/utils/events/mapEvents.ts +++ b/app/src/app/utils/events/mapEvents.ts @@ -33,14 +33,11 @@ export const handleMapClick = ( if (activeTool === "brush" || activeTool === "eraser") { const selectedFeatures = mapStore.paintFunction(map, e, mapStore.brushSize); - if (activeTool === "brush" && sourceLayer) { + if (sourceLayer) { // select on both the map object and the store SelectMapFeatures(selectedFeatures, map, mapStore).then(() => { SelectZoneAssignmentFeatures(mapStore); }); - } else if (activeTool === "eraser") { - // erase features - // TODO: implement eraser } } else { // tbd, for pan mode - is there an info mode on click? @@ -55,7 +52,7 @@ export const handleMapMouseUp = ( const activeTool = mapStore.activeTool; const isPainting = mapStore.isPainting; - if (activeTool === "brush" && isPainting) { + if ((activeTool === "brush" || activeTool === "eraser") && isPainting) { // set isPainting to false mapStore.setIsPainting(false); SelectZoneAssignmentFeatures(mapStore); @@ -75,12 +72,7 @@ export const handleMapMouseDown = ( } else if (activeTool === "brush" || activeTool === "eraser") { // disable drag pan map.current?.dragPan.disable(); - if (activeTool === "brush") { - mapStore.setIsPainting(true); - return; - } else if (activeTool === "eraser") { - // erase features tbd - } + mapStore.setIsPainting(true); } }; diff --git a/backend/app/alembic/versions/09d011c1b387_zones_can_be_null.py b/backend/app/alembic/versions/09d011c1b387_zones_can_be_null.py index a1f15eb6..d795225b 100644 --- a/backend/app/alembic/versions/09d011c1b387_zones_can_be_null.py +++ b/backend/app/alembic/versions/09d011c1b387_zones_can_be_null.py @@ -5,7 +5,6 @@ Create Date: 2024-09-09 13:34:59.347083 """ - from typing import Sequence, Union from alembic import op @@ -13,8 +12,8 @@ # revision identifiers, used by Alembic. -revision: str = "09d011c1b387" -down_revision: Union[str, None] = "8437ce954087" +revision: str = '09d011c1b387' +down_revision: Union[str, None] = '8437ce954087' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/backend/app/main.py b/backend/app/main.py index aceeada7..5516f4bc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -225,9 +225,7 @@ async def get_document(document_id: str, session: Session = Depends(get_session) async def get_total_population( document_id: str, session: Session = Depends(get_session) ): - stmt = text( - "SELECT * from get_total_population(:document_id) WHERE zone IS NOT NULL" - ) + stmt = text("SELECT * from get_total_population(:document_id) WHERE zone IS NOT NULL") try: result = session.execute(stmt, {"document_id": document_id}) return [ diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 396f5beb..8ada690a 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -406,6 +406,29 @@ def test_get_document_population_totals( assert data == [{"zone": 1, "total_pop": 67}, {"zone": 2, "total_pop": 130}] +def test_get_document_population_totals_null_assignments( + client, document_id, ks_demo_view_census_blocks +): + response = client.patch( + "/api/update_assignments", + json={ + "assignments": [ + {"document_id": document_id, "geo_id": "202090416004010", "zone": 1}, + {"document_id": document_id, "geo_id": "202090416003004", "zone": 1}, + {"document_id": document_id, "geo_id": "202090434001003", "zone": None}, + ] + }, + ) + assert response.status_code == 200 + assert response.json() == {"assignments_upserted": 3} + + doc_uuid = str(uuid.UUID(document_id)) + result = client.get(f"/api/document/{doc_uuid}/total_pop") + assert result.status_code == 200 + data = result.json() + assert data == [{"zone": 1, "total_pop": 67}] + + def test_get_document_vap_totals( client, assignments_document_id_total_vap, ks_demo_view_census_blocks_total_vap ):