Repository: concord-consortium/codap
Pull request #1892: Codap-6 Map Pins
Author: login: tealefristoe
Labels: name: v3
description: CODAP v3
name: run regression
Created: 2025-04-01T23:09:12Z | Merged: 2025-04-03T15:02:53Z

Jira story: https://concord-consortium.atlassian.net/browse/CODAP-6

This PR adds support for pins/markers to Codap maps.
- Pins are enabled if a dataset has an appropriately named latitude and longitude attribute.
  - The attribute keyword names are the same as point latitude and longitude names with `pin` appended to them.
- When pins are enabled, add pin and remove pin buttons are displayed on the map.
  - Clicking the add pin button enters add pin mode. The next time the user clicks of the map, a new pin will be added where the user clicked (a new case is added to the pin table with the correct latitude and longitude), and then add pin mode is exited.
  - Clicking the remove pin button will immediately remove all selected pins (removing their cases from the pin table).
- Pin selection is tied to selecting the corresponding case in the pin table.
  - Clicking a pin will select it.
  - Shift+clicking a pin will add or remove it from the current selection.
- Pins are colored in order according to a set of ten colors.
  - If a color attribute is in the pin table, the color is stored here. Changing this color will change the color of the pin.
  - If no color attribute is in the pin table, the color is based on the index. This isn't great, because pins will change colors if their case order changes (for example, removing an earlier pin). However, I didn't think it would be appropriate to add a color attribute if one wasn't already present.

There are a few places where I left `FIX-ME` comments in the code, where I welcome suggestions on how to improve things.
- `z-index` for the pin layer and pin controls.
- Hard coded pin svg dimensions.


Diff:
diff --git a/v3/cypress/e2e/map.spec.ts b/v3/cypress/e2e/map.spec.ts
index ab649589ff..72813fafaa 100644
--- a/v3/cypress/e2e/map.spec.ts
+++ b/v3/cypress/e2e/map.spec.ts
@@ -3,6 +3,7 @@ import { ComponentElements as c } from "../support/elements/component-elements"
 import { ToolbarElements as toolbar } from "../support/elements/toolbar-elements"
 import { CfmElements as cfm } from "../support/elements/cfm"
 import { MapLegendHelper as mlh } from "../support/helpers/map-legend-helper"
+import { TableTileElements as table } from "../support/elements/table-tile"
 
 const filename1 = "cypress/fixtures/RollerCoastersWithLatLong.csv"
 const filename2 = "cypress/fixtures/map-data.csv"
@@ -206,6 +207,59 @@ context("Map UI", () => {
     mlh.removeAttributeFromLegend("Length")
     map.getHeatmapCanvas().should("not.exist")
   })
+  it("checks map pins", () => {
+    c.getIconFromToolShelf("table").click()
+    toolbar.getNewCaseTable().click()
+    c.getResizeControl("table")
+      .realMouseDown({ position: "center" })
+      .realMouseMove(350, 0)
+      .realMouseUp()
+    table.addNewAttribute(1, "pinLat")
+    table.addNewAttribute(1, "pinLong")
+    // TODO: Test colors. I wasn't able to figure out how to set the type to color.
+    // table.addNewAttribute(1, "pinColor")
+    // table.editAttributeProperties("pinColor", "", null, "color")
+
+    c.getIconFromToolShelf("map").click()
+    map.getMapTile().should("exist")
+    map.getPinLayer().should("exist")
+    map.getAddPinButton().should("exist")
+    map.getRemovePinButton().should("exist").should("be.disabled")
+
+    // Can add a pin
+    table.getNumOfRows().should("eq", "2") // Headers + input row
+    map.getAddPinButton().click()
+    map.getAddPinButton().should("have.class", "active")
+    map.getPinLayer().click()
+    map.getAddPinButton().should("not.have.class", "active")
+    map.getMapPin().should("exist")
+    table.getNumOfRows().should("eq", "3")
+
+    // Can select a pin
+    table.getSelectedRows().should("have.length", 0)
+    map.getMapPin().should("not.have.class", "selected-pin")
+    map.getMapPin().click()
+    map.getMapPin().should("have.class", "selected-pin")
+    table.getSelectedRows().should("have.length", 1)
+
+    // Can deselect a pin by shift+clicking
+    map.getMapPin().click({ shiftKey: true })
+    map.getMapPin().should("not.have.class", "selected-pin")
+    table.getSelectedRows().should("have.length", 0)
+
+    // Pin is selected when the table row is selected
+    table.getCell(2, 2).click()
+    map.getMapPin().should("have.class", "selected-pin")
+    table.getSelectedRows().should("have.length", 1)
+
+    // Can remove a pin using the remove pin button
+    c.getComponentTitleBar("map").click()
+    map.getRemovePinButton().should("not.be.disabled")
+    map.getRemovePinButton().click()
+    map.getRemovePinButton().should("be.disabled")
+    map.getMapPins().should("not.exist")
+    table.getNumOfRows().should("eq", "2") // Headers + input row
+  })
   // flaky test skipped in PR #1239, see PT #187534790
   it.skip("checks show/hide map boundaries with legend selections", () => {
     cfm.openLocalDoc(filename2)
diff --git a/v3/cypress/support/elements/map-tile.ts b/v3/cypress/support/elements/map-tile.ts
index f612444e96..7b482eb0da 100644
--- a/v3/cypress/support/elements/map-tile.ts
+++ b/v3/cypress/support/elements/map-tile.ts
@@ -35,6 +35,21 @@ export const MapTileElements = {
   getZoomOutButton() {
     return this.getMapTile().find(".leaflet-control-zoom-out")
   },
+  getAddPinButton() {
+    return this.getMapTile().find("[data-testid=add-pin-button]")
+  },
+  getRemovePinButton() {
+    return this.getMapTile().find("[data-testid=remove-pin-button]")
+  },
+  getPinLayer() {
+    return this.getMapTile().find(".map-pin-layer")
+  },
+  getMapPins() {
+    return this.getPinLayer().find(".map-pin")
+  },
+  getMapPin(index=0) {
+    return this.getMapPins().eq(index)
+  },
   getHideSelectedCases() {
     return c.getInspectorPanel().find("[data-testid=hide-selected-cases]")
   },
diff --git a/v3/cypress/support/elements/table-tile.ts b/v3/cypress/support/elements/table-tile.ts
index 501cda0ba8..20f94928dd 100644
--- a/v3/cypress/support/elements/table-tile.ts
+++ b/v3/cypress/support/elements/table-tile.ts
@@ -392,10 +392,15 @@ export const TableTileElements = {
       })
     }
   },
-  addNewAttribute(collectionIndex = 1) {
+  addNewAttribute(collectionIndex = 1, attributeName?: string) {
     this.getCollection(collectionIndex).find("[data-testid=collection-add-attribute-icon-button] svg")
       .click({force:true})
-    cy.get("[data-testid=column-name-input]").type("{enter}{enter}")
+    if (attributeName) {
+      cy.wait(100)
+      this.getAttributeInput(collectionIndex).type(`${attributeName}{enter}`)
+    } else {
+      cy.get("[data-testid=column-name-input]").type("{enter}{enter}")
+    }
   },
   deleteAttribute(attributeName: string, collectionIndex = 1) {
     this.openAttributeMenu(attributeName, collectionIndex)
diff --git a/v3/src/components/data-display/data-display-types.ts b/v3/src/components/data-display/data-display-types.ts
index 35320286f2..ce782bfffc 100644
--- a/v3/src/components/data-display/data-display-types.ts
+++ b/v3/src/components/data-display/data-display-types.ts
@@ -61,7 +61,7 @@ export type GraphAttrRole = typeof GraphAttrRoles[number]
 export const GraphSplitAttrRoles = [...PrimaryAttrRoles, 'topSplit', 'rightSplit'] as const
 export type GraphSplitAttrRole = typeof GraphSplitAttrRoles[number]
 
-export const MapAttrRoles = ['lat', 'long', 'polygon'] as const
+export const MapAttrRoles = ['lat', 'long', 'pinLat', 'pinLong', 'polygon'] as const
 export type MapAttrRole = typeof MapAttrRoles[number]
 
 export const AttrRoles = [...GraphAttrRoles, ...MapAttrRoles] as const
diff --git a/v3/src/components/data-display/models/data-display-layer-union.ts b/v3/src/components/data-display/models/data-display-layer-union.ts
index 0b88cdca50..42bb912bd6 100644
--- a/v3/src/components/data-display/models/data-display-layer-union.ts
+++ b/v3/src/components/data-display/models/data-display-layer-union.ts
@@ -4,7 +4,8 @@ import {GraphPointLayerModel, IGraphPointLayerModel, IGraphPointLayerModelSnapsh
   from "../../graph/models/graph-point-layer-model"
 import {IMapPointLayerModel, IMapPointLayerModelSnapshot, MapPointLayerModel}
   from "../../map/models/map-point-layer-model"
-import {kMapPointLayerType, kMapPolygonLayerType} from "../../map/map-types"
+import {kMapPinLayerType, kMapPointLayerType, kMapPolygonLayerType} from "../../map/map-types"
+import { IMapPinLayerModel, IMapPinLayerModelSnapshot, MapPinLayerModel } from "../../map/models/map-pin-layer-model"
 import {IMapBaseLayerModel, IMapBaseLayerModelSnapshot, kMapBaseLayerType, MapBaseLayerModel}
   from "../../map/models/map-base-layer-model"
 import {IMapPolygonLayerModel, IMapPolygonLayerModelSnapshot, MapPolygonLayerModel}
@@ -27,6 +28,7 @@ const dataDisplayLayerTypeDispatcher = (displayLayerModelSnap: IDataDisplayLayer
   switch (displayLayerModelSnap.type) {
     case kGraphPointLayerType: return GraphPointLayerModel
     case kMapBaseLayerType: return MapBaseLayerModel
+    case kMapPinLayerType: return MapPinLayerModel
     case kMapPointLayerType: return MapPointLayerModel
     case kMapPolygonLayerType: return MapPolygonLayerModel
     default: return UnknownDataDisplayLayerModel
@@ -34,8 +36,8 @@ const dataDisplayLayerTypeDispatcher = (displayLayerModelSnap: IDataDisplayLayer
 }
 
 export const DataDisplayLayerModelUnion = types.union({ dispatcher: dataDisplayLayerTypeDispatcher },
-  GraphPointLayerModel, MapBaseLayerModel, MapPolygonLayerModel, MapPointLayerModel)
+  GraphPointLayerModel, MapBaseLayerModel, MapPinLayerModel, MapPolygonLayerModel, MapPointLayerModel)
 export type IDataDisplayLayerModelUnion =
-  IGraphPointLayerModel | IMapBaseLayerModel | IMapPolygonLayerModel | IMapPointLayerModel
+  IGraphPointLayerModel | IMapBaseLayerModel | IMapPinLayerModel | IMapPolygonLayerModel | IMapPointLayerModel
 export type IDataDisplayLayerSnapshotUnion = IGraphPointLayerModelSnapshot |
-  IMapBaseLayerModelSnapshot | IMapPolygonLayerModelSnapshot | IMapPointLayerModelSnapshot
+  IMapBaseLayerModelSnapshot | IMapPinLayerModelSnapshot | IMapPolygonLayerModelSnapshot | IMapPointLayerModelSnapshot
diff --git a/v3/src/components/map/assets/add-location-marker-icon.svg b/v3/src/components/map/assets/add-location-marker-icon.svg
new file mode 100644
index 0000000000..710565c867
--- /dev/null
+++ b/v3/src/components/map/assets/add-location-marker-icon.svg
@@ -0,0 +1,6 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+    <g fill="none" fill-rule="evenodd">
+        <path d="M0 0h24v24H0z"/>
+        <path class="icon" d="M12.003 1a7.3 7.3 0 0 1 4.147 1.284l.074.053a6.004 6.004 0 0 0-2.01 3.071 3.667 3.667 0 1 0 .83 4.974 6.008 6.008 0 0 0 3.107 2.328c-1.301 2.528-3.489 5.206-5.582 9.446l-.232.477c-.234.49-.433.49-.667 0-3.333-6.966-7-9.804-7-14.3A7.333 7.333 0 0 1 12.003 1zM21 3l-.001 3H24v2h-3.001L21 11h-2l-.001-3H16V6h2.999L19 3h2z" fill="#177991" fill-rule="nonzero"/>
+    </g>
+</svg>
diff --git a/v3/src/components/map/assets/placed-location-marker.svg b/v3/src/components/map/assets/placed-location-marker.svg
new file mode 100644
index 0000000000..6147d0e24f
--- /dev/null
+++ b/v3/src/components/map/assets/placed-location-marker.svg
@@ -0,0 +1,11 @@
+<svg width="25" height="35" viewBox="0 0 25 35" xmlns="http://www.w3.org/2000/svg">
+    <g fill="none" fill-rule="evenodd">
+        <path d="M0 0h25v35H0z"/>
+        <g fill-rule="nonzero">
+            <path class="selection-outline" d="M12.5 0C19.404 0 25 5.596 25 12.5c0 2.031-.443 3.927-1.314 5.902-.432.981-1.843 3.332-3.338 5.779l-.675 1.103c-1.424 2.325-2.803 4.565-3.369 5.628-.379.713-.743 1.433-1.094 2.167-1.226 2.561-4.194 2.562-5.42 0a56.918 56.918 0 0 0-1.094-2.167c-1.25-2.35-6.473-10.448-7.381-12.507C.444 16.43 0 14.532 0 12.5 0 5.596 5.596 0 12.5 0zm0 10a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z"/>
+            <path d="M12.5 1.5c6.075 0 11 4.925 11 11 0 1.81-.396 3.503-1.187 5.298-.85 1.928-6.031 9.962-7.333 12.41-.39.731-.763 1.47-1.123 2.224-.682 1.424-2.032 1.424-2.714 0a58.418 58.418 0 0 0-1.123-2.225C8.718 27.76 3.537 19.727 2.687 17.8c-.79-1.795-1.187-3.489-1.187-5.3 0-6.075 4.925-11 11-11zm0 7a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" fill="#FFF"/>
+            <path d="M12.5 2.5c5.523 0 10 4.477 10 10 0 6.13-5 10-9.545 19.5-.32.667-.59.667-.91 0C7.5 22.5 2.5 18.63 2.5 12.5c0-5.523 4.477-10 10-10zm0 5a5 5 0 1 0 0 10 5 5 0 0 0 0-10z" fill="#000"/>
+            <path d="M12.5 3.5a9 9 0 0 0-9 9c0 1.517.333 2.94 1.018 4.493.772 1.752 5.896 9.697 7.268 12.275.134.252.266.505.397.76l.317.627.317-.628.397-.759c1.371-2.577 6.497-10.524 7.27-12.277.683-1.552 1.016-2.975 1.016-4.491a9 9 0 0 0-9-9zm0 3a6 6 0 1 1 0 12 6 6 0 0 1 0-12z" fill="currentColor"/>
+        </g>
+    </g>
+</svg>
diff --git a/v3/src/components/map/assets/remove-location-marker-icon.svg b/v3/src/components/map/assets/remove-location-marker-icon.svg
new file mode 100644
index 0000000000..0dea019598
--- /dev/null
+++ b/v3/src/components/map/assets/remove-location-marker-icon.svg
@@ -0,0 +1,6 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+    <g fill="none" fill-rule="evenodd">
+        <path d="M0 0h24v24H0z"/>
+        <path class="icon" d="M12.003 1a7.3 7.3 0 0 1 4.147 1.284l.074.053a6.004 6.004 0 0 0-2.01 3.071 3.667 3.667 0 1 0 .83 4.974 6.008 6.008 0 0 0 3.107 2.328c-1.301 2.528-3.489 5.206-5.582 9.446l-.232.477c-.234.49-.433.49-.667 0-3.333-6.966-7-9.804-7-14.3A7.333 7.333 0 0 1 12.003 1zM24 6v2h-8V6h8z" fill="#177991" fill-rule="nonzero"/>
+    </g>
+</svg>
diff --git a/v3/src/components/map/components/map-interior.tsx b/v3/src/components/map/components/map-interior.tsx
index 13798b33e0..a156aef7cc 100644
--- a/v3/src/components/map/components/map-interior.tsx
+++ b/v3/src/components/map/components/map-interior.tsx
@@ -3,13 +3,15 @@ import React, {useEffect} from "react"
 import {PixiPoints} from "../../data-display/pixi/pixi-points"
 import {useMapModelContext} from "../hooks/use-map-model-context"
 import {useMapModel} from "../hooks/use-map-model"
-import {kMapPointLayerType, kMapPolygonLayerType} from "../map-types"
+import {kMapPinLayerType, kMapPointLayerType, kMapPolygonLayerType} from "../map-types"
 import {isMapPointLayerModel} from "../models/map-point-layer-model"
 import {MapPointLayer} from "./map-point-layer"
 import {isMapPolygonLayerModel} from "../models/map-polygon-layer-model"
 import {MapPolygonLayer} from "./map-polygon-layer"
 import { DataConfigurationContext } from "../../data-display/hooks/use-data-configuration-context"
 import { useTileModelContext } from "../../../hooks/use-tile-model-context"
+import { isMapPinLayerModel } from "../models/map-pin-layer-model"
+import { MapPinLayer } from "./map-pin-layer"
 
 interface IProps {
   setPixiPointsLayer: (pixiPoints: PixiPoints, layerIndex: number) => void
@@ -51,12 +53,14 @@ export const MapInterior = observer(function MapInterior({setPixiPointsLayer}: I
           mapLayerModel={layerModel}
         />
       }
+      else if (isMapPinLayerModel(layerModel)) {
+        return <MapPinLayer
+          key={`${kMapPinLayerType}-${index}`}
+          mapLayerModel={layerModel}
+        />
+      }
     })
   }
 
-  return (
-    <>
-      {renderMapLayerComponents()}
-    </>
-  )
+  return renderMapLayerComponents()
 })
diff --git a/v3/src/components/map/components/map-pin-layer.scss b/v3/src/components/map/components/map-pin-layer.scss
new file mode 100644
index 0000000000..5dcebdc832
--- /dev/null
+++ b/v3/src/components/map/components/map-pin-layer.scss
@@ -0,0 +1,26 @@
+.map-pin-layer {
+  height: 100%;
+  position: absolute;
+  width: 100%;
+  z-index: 10;
+
+  &.add-mode {
+    cursor: pointer;
+  }
+
+  .map-pin {
+    border: none;
+    margin: 0;
+    position: absolute;
+
+    .selection-outline {
+      fill: none;
+    }
+
+    &.selected-pin {
+      .selection-outline {
+        fill: red;
+      }
+    }
+  }
+}
diff --git a/v3/src/components/map/components/map-pin-layer.tsx b/v3/src/components/map/components/map-pin-layer.tsx
new file mode 100644
index 0000000000..c736cebcec
--- /dev/null
+++ b/v3/src/components/map/components/map-pin-layer.tsx
@@ -0,0 +1,135 @@
+import clsx from "clsx"
+import { observer } from "mobx-react-lite"
+import React, { useEffect, useRef } from "react"
+import { useMap } from "react-leaflet"
+import { useForceUpdate } from "../../../hooks/use-force-update"
+import { IDataSet } from "../../../models/data/data-set"
+import { isSetCaseValuesAction } from "../../../models/data/data-set-actions"
+import { ICaseCreation } from "../../../models/data/data-set-types"
+import { insertCasesWithCustomUndoRedo } from "../../../models/data/data-set-undo"
+import { selectCases, setSelectedCases } from "../../../models/data/data-set-utils"
+import { onAnyAction } from "../../../utilities/mst-utils"
+import { useDataDisplayLayout } from "../../data-display/hooks/use-data-display-layout"
+import PlacedLocationMarker from "../assets/placed-location-marker.svg"
+import { useMapModelContext } from "../hooks/use-map-model-context"
+import { kPinColors } from "../map-types"
+import { IMapPinLayerModel } from "../models/map-pin-layer-model"
+import { datasetHasPinData, pinAttributesFromDataSet } from "../utilities/map-utils"
+import { PinControls } from "./pin-controls"
+import "./map-pin-layer.scss"
+
+const mapPinHeight = 35
+const mapPinWidth = 25
+
+interface IMapPinProps {
+  color?: string
+  dataset: IDataSet
+  id: string
+  key?: string
+  selected?: boolean
+  x: number
+  y: number
+}
+function MapPin({ color="#0068EA", dataset, id, selected, x, y }: IMapPinProps) {
+  const handleClick = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (e.shiftKey) {
+      selectCases([id], dataset, !selected)
+    } else {
+      setSelectedCases([id], dataset)
+    }
+  }
+
+  return (
+    <button
+      className={clsx("map-pin", { "selected-pin": selected })}
+      onClick={handleClick}
+      style={{ left: x, top: y }}
+    >
+      <PlacedLocationMarker color={color} />
+    </button>
+  )
+}
+
+interface IMapPinLayerProps {
+  mapLayerModel: IMapPinLayerModel
+}
+export const MapPinLayer = observer(function MapPinLayer({ mapLayerModel }: IMapPinLayerProps) {
+  const forceUpdate = useForceUpdate()
+  const layerRef = useRef<HTMLDivElement>(null)
+  const map = useMap()
+  const mapModel = useMapModelContext()
+  const layout = useDataDisplayLayout()
+
+  const { dataConfiguration } = mapLayerModel
+  const dataset = dataConfiguration?.dataset
+
+  // Force a rerender when any relevant values change
+  useEffect(() => {
+    if (dataset) {
+      return onAnyAction(dataset, action => {
+        if (isSetCaseValuesAction(action)) {
+          forceUpdate()
+        }
+      })
+    }
+  }, [dataset, forceUpdate])
+
+  // Force a rerender when the map is resized, panned, or zoomed
+  const { contentWidth: _contentWidth, contentHeight: _contentHeight } = layout
+  const { center: _center, zoom: _zoom } = mapModel.leafletMapState
+
+  // Bail if the dataset doesn't have the data we need
+  if (!dataset || !datasetHasPinData(dataset)) return
+
+  const { pinLatId, pinLongId } = pinAttributesFromDataSet(dataset)
+  const colorId = dataset.attributes.find(attr => attr.type === "color")?.id
+
+  const handleClick = (e: React.MouseEvent) => {
+    if (mapLayerModel.addMode) {
+      e.stopPropagation()
+      mapLayerModel.setAddMode(false)
+
+      const layerBB = layerRef.current?.getBoundingClientRect()
+      if (!layerBB || !dataset) return
+      const { lat, lng } = map.containerPointToLatLng([e.clientX - layerBB.x, e.clientY - layerBB.y])
+      const color = kPinColors[(dataset.items.length ?? 0) % kPinColors.length]
+      const newItem: ICaseCreation = { [pinLatId]: lat, [pinLongId]: lng }
+      if (colorId) newItem[colorId] = color
+      insertCasesWithCustomUndoRedo(dataset, [newItem])
+    }
+  }
+
+  const renderPins = mapLayerModel.isVisible
+  return (
+    <div
+      className={clsx("map-pin-layer", { "add-mode": mapLayerModel.addMode })}
+      onClick={handleClick}
+      ref={layerRef}
+    >
+      {renderPins && dataset.items.map(({ __id__ }, index) => {
+        const lat = dataset.getNumeric(__id__, pinLatId)
+        const long = dataset.getNumeric(__id__, pinLongId)
+        if (lat == null || long == null || !isFinite(lat) || !isFinite(long)) return null
+
+        const { x, y } = map.latLngToContainerPoint([lat, long])
+        const pinX = x - mapPinWidth / 2
+        const pinY = y - mapPinHeight
+        
+        const color = colorId ? dataset.getStrValue(__id__, colorId) : kPinColors[index % kPinColors.length]
+        return (
+          <MapPin
+            color={color}
+            dataset={dataset}
+            id={__id__}
+            key={`pin-${__id__}`}
+            selected={dataset?.isCaseSelected(__id__)}
+            x={pinX}
+            y={pinY}
+          />
+        )
+      })}
+      <PinControls mapLayerModel={mapLayerModel} />
+    </div>
+  )
+})
diff --git a/v3/src/components/map/components/pin-controls.scss b/v3/src/components/map/components/pin-controls.scss
new file mode 100644
index 0000000000..8dfb7365e9
--- /dev/null
+++ b/v3/src/components/map/components/pin-controls.scss
@@ -0,0 +1,48 @@
+.pin-controls {
+  display: flex;
+  flex-direction: column;
+  left: 10px;
+  position: absolute;
+  top: 90px;
+  z-index: 11;
+
+  .map-control-button {
+    align-items: center;
+    background-color: white;
+    border: 2px solid #aac8d2;
+    display: flex;
+    height: 35px;
+    justify-content: center;
+    padding: 0;
+    width: 35px;
+
+    &.active {
+      background-color: #b9dfe5;
+    }
+
+    &:disabled {
+      .icon {
+        opacity: .4;
+      }
+    }
+  
+    &:active {
+      background-color: #b9dfe5;
+    }
+
+    &:hover {
+      background-color: #dceff2;
+    }
+
+    &.bottom-button {
+      border-radius: 0 0 4.5px 4.5px;
+      border-top: none;
+    }
+
+    &.top-button {
+      border-radius: 4.5px 4.5px 0 0;
+      border-bottom: 1px solid #aac8d2;
+      height: 36px;
+    }
+  }
+}
diff --git a/v3/src/components/map/components/pin-controls.tsx b/v3/src/components/map/components/pin-controls.tsx
new file mode 100644
index 0000000000..165f1d18a1
--- /dev/null
+++ b/v3/src/components/map/components/pin-controls.tsx
@@ -0,0 +1,71 @@
+import clsx from "clsx"
+import { observer } from "mobx-react-lite"
+import React from "react"
+import { removeCasesWithCustomUndoRedo } from "../../../models/data/data-set-undo"
+import AddIcon from "../assets/add-location-marker-icon.svg"
+import RemoveIcon from "../assets/remove-location-marker-icon.svg"
+import { kPinColors } from "../map-types"
+import { IMapPinLayerModel } from "../models/map-pin-layer-model"
+import "./pin-controls.scss"
+
+interface IControlButtonProps {
+  active?: boolean
+  className?: string
+  disabled?: boolean
+  Icon?: any
+  onClick?: React.MouseEventHandler<HTMLButtonElement>
+  testId?: string
+}
+function ControlButton({ active, className, disabled, Icon, onClick, testId }: IControlButtonProps) {
+  return (
+    <button
+      className={clsx("map-control-button", className, { active, disabled })}
+      data-testid={testId}
+      disabled={disabled}
+      onClick={onClick}
+      type="button"
+    >
+      {Icon && <Icon className="map-control-button-icon" />}
+    </button>
+  )
+}
+
+interface IPinControlsProps {
+  mapLayerModel: IMapPinLayerModel
+}
+export const PinControls = observer(function PinControls({ mapLayerModel }: IPinControlsProps) {
+  const dataset = mapLayerModel.dataConfiguration.dataset
+
+  const addButtonDisabled = (dataset?.items.length ?? 0) >= kPinColors.length
+  const removeButtonDisabled = !dataset?.selection.size
+
+  const handleAddButtonClick = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    mapLayerModel.setAddMode(!mapLayerModel.addMode)
+  }
+  const handleRemoveButtonClick = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (dataset) {
+      removeCasesWithCustomUndoRedo(dataset, Array.from(dataset.selection))
+    }
+  }
+  return (
+    <div className="pin-controls">
+      <ControlButton
+        active={mapLayerModel.addMode}
+        className="top-button"
+        disabled={addButtonDisabled}
+        Icon={AddIcon}
+        onClick={handleAddButtonClick}
+        testId="add-pin-button"
+      />
+      <ControlButton
+        className="bottom-button"
+        disabled={removeButtonDisabled}
+        Icon={RemoveIcon}
+        onClick={handleRemoveButtonClick}
+        testId="remove-pin-button"
+      />
+    </div>
+  )
+})
diff --git a/v3/src/components/map/map-types.ts b/v3/src/components/map/map-types.ts
index 3144a2fa50..b0a99ca960 100644
--- a/v3/src/components/map/map-types.ts
+++ b/v3/src/components/map/map-types.ts
@@ -12,6 +12,7 @@ export const kMapUrls: Record<BaseMapKey, string> = {
 
 export const kMapPolygonLayerType = "mapPolygonLayer"
 export const kMapPointLayerType = "mapPointLayer"
+export const kMapPinLayerType = "mapPinLayer"
 
 export const kMapClass = "codap-map"
 export const kMapClassSelector = `.${kMapClass}`
@@ -46,7 +47,16 @@ export const
 
 
   kLatNames = ['latitude', 'lat', 'latitud', 'breitengrad', '緯度', 'عرض جغرافیایی'],
-  kLongNames = ['longitude', 'long', 'lng', 'lon', 'longitud', 'längengrad', '経度', 'طول جغرافیایی']
+  kLongNames = ['longitude', 'long', 'lng', 'lon', 'longitud', 'längengrad', '経度', 'طول جغرافیایی'],
+
+  // TODO: Translate "pin" in non-English languages.
+  kPinLatNames = ['pinlatitude', 'pinlat', 'pinlatitud', 'pinbreitengrad', 'pin緯度', 'pinعرض جغرافیایی'],
+  kPinLongNames = [
+    'pinlongitude', 'pinlong', 'pinlng', 'pinlon', 'pinlongitud', 'pinlängengrad', 'pin経度', 'pinطول جغرافیایی'
+  ],
+  kPinColors = [
+    '#0068EA', '#E86B11', '#16A76A', '#E40029', '#2A31A4', '#D51EFF', '#4769A1', '#800000', '#949494', '#000000'
+  ]
 
 export const MapPlaces = ['map', 'legend'] as const
 export type MapPlace = typeof MapPlaces[number]
diff --git a/v3/src/components/map/models/map-content-model.ts b/v3/src/components/map/models/map-content-model.ts
index 856c263c66..01a3d46fdf 100644
--- a/v3/src/components/map/models/map-content-model.ts
+++ b/v3/src/components/map/models/map-content-model.ts
@@ -16,11 +16,13 @@ import {DataDisplayContentModel} from "../../data-display/models/data-display-co
 import {kMapModelName, kMapTileType} from "../map-defs"
 import {BaseMapKey, BaseMapKeys} from "../map-types"
 import {
-  datasetHasBoundaryData, datasetHasLatLongData, expandLatLngBounds, getLatLongBounds, latLongAttributesFromDataSet
+  datasetHasBoundaryData, datasetHasLatLongData, datasetHasPinData, expandLatLngBounds, getLatLongBounds,
+  latLongAttributesFromDataSet, pinAttributesFromDataSet
 } from "../utilities/map-utils"
 import {ILatLngSnapshot, LatLngModel} from "../map-model-types"
 import {LeafletMapState} from "./leaflet-map-state"
 import {isMapLayerModel} from "./map-layer-model"
+import { isMapPinLayerModel, MapPinLayerModel } from "./map-pin-layer-model"
 import {isMapPointLayerModel, MapPointLayerModel} from "./map-point-layer-model"
 import {isMapPolygonLayerModel, MapPolygonLayerModel} from "./map-polygon-layer-model"
 
@@ -163,6 +165,16 @@ export const MapContentModel = DataDisplayContentModel
       newPolygonLayer.setDataset(dataSet)
       return newPolygonLayer
     },
+    addPinLayer(dataSet: IDataSet) {
+      const newPinLayer = MapPinLayerModel.create()
+      self.layers.push(newPinLayer) // We have to do this first so safe references will work
+      const dataConfiguration = newPinLayer.dataConfiguration,
+        { pinLatId, pinLongId } = pinAttributesFromDataSet(dataSet)
+      dataConfiguration.setDataset(dataSet, getSharedCaseMetadataFromDataset(dataSet))
+      dataConfiguration.setAttribute('pinLat', {attributeID: pinLatId})
+      dataConfiguration.setAttribute('pinLong', {attributeID: pinLongId})
+      return newPinLayer
+    },
     afterCreate() {
       addDisposer(self, () => self.leafletMapState.destroy())
 
@@ -272,6 +284,18 @@ export const MapContentModel = DataDisplayContentModel
                 this.addPolygonLayer(sharedDataSet.dataSet)
               }
             }
+            if (datasetHasPinData(sharedDataSet.dataSet)) {
+              const pinLayer = layersToCheck.find(layer => {
+                return layer.data === sharedDataSet.dataSet && isMapPinLayerModel(layer)
+              })
+              if (isMapPinLayerModel(pinLayer)) {
+                pinLayer.setDataset(sharedDataSet.dataSet)
+                layersToCheck.splice(layersToCheck.indexOf(pinLayer), 1)
+              } else {
+                // Add a new layer for this dataset
+                this.addPinLayer(sharedDataSet.dataSet)
+              }
+            }
           })
           // Remove any remaining layers in layersToCheck since they are no longer in any shared dataset
           layersToCheck.forEach(layer => {
diff --git a/v3/src/components/map/models/map-pin-layer-model.ts b/v3/src/components/map/models/map-pin-layer-model.ts
new file mode 100644
index 0000000000..6f6cfe8eeb
--- /dev/null
+++ b/v3/src/components/map/models/map-pin-layer-model.ts
@@ -0,0 +1,57 @@
+import {Instance, SnapshotIn, types} from "mobx-state-tree"
+import {IDataSet} from "../../../models/data/data-set"
+import {getSharedCaseMetadataFromDataset} from "../../../models/shared/shared-data-utils"
+import {computePointRadius} from "../../data-display/data-display-utils"
+import {IDataDisplayLayerModel} from "../../data-display/models/data-display-layer-model"
+import {kMapPinLayerType} from "../map-types"
+import {latLongAttributesFromDataSet} from "../utilities/map-utils"
+import {MapGridModel} from "./map-grid-model"
+import {MapLayerModel} from "./map-layer-model"
+
+export const MapPinLayerModel = MapLayerModel
+  .named('MapPinLayerModel')
+  .props({
+    type: types.optional(types.literal(kMapPinLayerType), kMapPinLayerType),
+    gridModel: types.optional(MapGridModel, () => MapGridModel.create()),
+    pinsAreVisible: true, // This is different than layer visibility
+  })
+  .volatile(self => ({
+    addMode: false
+  }))
+  .actions(self => ({
+    afterCreate() {
+      self.gridModel.setDataConfiguration(self.dataConfiguration)
+    },
+    setDataset(dataSet:IDataSet) {
+      const {latId, longId} = latLongAttributesFromDataSet(dataSet)
+      self.dataConfiguration.setDataset(dataSet, getSharedCaseMetadataFromDataset(dataSet))
+      self.dataConfiguration.setAttribute('lat', {attributeID: latId})
+      self.dataConfiguration.setAttribute('long', {attributeID: longId})
+    },
+    setPinsAreVisible(isVisible: boolean) {
+      self.pinsAreVisible = isVisible
+    },
+    setAddMode(addMode: boolean) {
+      self.addMode = addMode
+    }
+  }))
+  .views(self => ({
+    getPointRadius(use: 'normal' | 'hover-drag' | 'select' = 'normal') {
+      return computePointRadius(self.dataConfiguration.getCaseDataArray(0).length,
+        self.displayItemDescription.pointSizeMultiplier, use)
+    },
+    get pointDescription() {
+      return self.displayItemDescription
+    }
+  }))
+
+
+export interface IMapPinLayerModel extends Instance<typeof MapPinLayerModel> {
+}
+
+export interface IMapPinLayerModelSnapshot extends SnapshotIn<typeof MapPinLayerModel> {
+}
+
+export function isMapPinLayerModel(layerModel?: IDataDisplayLayerModel): layerModel is IMapPinLayerModel {
+  return layerModel?.type === kMapPinLayerType
+}
diff --git a/v3/src/components/map/utilities/map-utils.ts b/v3/src/components/map/utilities/map-utils.ts
index 4e8513f7c7..7a52f91487 100644
--- a/v3/src/components/map/utilities/map-utils.ts
+++ b/v3/src/components/map/utilities/map-utils.ts
@@ -5,7 +5,7 @@ import {IDataSet} from "../../../models/data/data-set"
 import {isFiniteNumber} from "../../../utilities/math-utils"
 import {translate} from "../../../utilities/translation/translate"
 import {IDataConfigurationModel} from "../../data-display/models/data-configuration-model"
-import {kLatNames, kLongNames} from "../map-types"
+import {kLatNames, kLongNames, kPinLatNames, kPinLongNames} from "../map-types"
 
 // A dataset has point data if it has both a latitude and longitude attribute; i.e. an attribute whose name
 // is in kLatNames and an attribute whose name is in kLongNames
@@ -40,6 +40,23 @@ export const datasetHasBoundaryData = (dataset: IDataSet) => {
   return hasBoundaryAttribute
 }
 
+export const datasetHasPinData = (dataset: IDataSet) => {
+  const attrNames = dataset.attributes.map(attr => attr.name.toLowerCase())
+  let hasPinLatAttribute = false,
+    hasPinLngAttribute = false
+  while (attrNames.length > 0 && (!hasPinLatAttribute || !hasPinLngAttribute)) {
+    const attrName = attrNames.pop()
+    if (attrName) {
+      if (kPinLatNames.includes(attrName)) {
+        hasPinLatAttribute = true
+      } else if (kPinLongNames.includes(attrName)) {
+        hasPinLngAttribute = true
+      }
+    }
+  }
+  return hasPinLatAttribute && hasPinLngAttribute
+}
+
 // Returns the attribute IDs for the latitude and longitude attributes in the given dataset
 // Throws an error if the dataset does not have both latitude and longitude attributes
 export const latLongAttributesFromDataSet = (dataSet: IDataSet) => {
@@ -64,6 +81,19 @@ export const boundaryAttributeFromDataSet = (dataSet: IDataSet) => {
   return polygonAttr.id
 }
 
+export const pinAttributesFromDataSet = (dataSet: IDataSet) => {
+  const attributes = dataSet.attributes,
+    pinLatAttr = attributes.find(attr => kPinLatNames.includes(attr.name.toLowerCase())),
+    pinLongAttr = attributes.find(attr => kPinLongNames.includes(attr.name.toLowerCase()))
+  if (!pinLatAttr || !pinLongAttr) {
+    throw new Error(`Unable to find both pin latitude and longitude attributes in dataset ${dataSet.name}`)
+  }
+  return {
+    pinLatId: pinLatAttr.id,
+    pinLongId: pinLongAttr.id,
+  }
+}
+
 /**
  * Returns the lat/long bounds for the given data configuration
  * Todo: Currently only deals with lat/long values. Extend to work with polygons as well.


Reviews:
user: login: kswenson
body: 👍 Looks good -- a handful of suggestions that should be straightforward. I also added @bfinzer as a reviewer to at least take a look at the layer architecture and play around with the UI.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-02T19:13:27Z
user: login: bfinzer
body: Just a few minor suggestions and a crash to fix
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T22:14:53Z
user: login: bfinzer
body: 👍🏻LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T14:58:31Z

Review comments:
diff_hunk: @@ -392,10 +392,15 @@ export const TableTileElements = {
       })
     }
   },
-  addNewAttribute(collectionIndex = 1) {
+  addNewAttribute(collectionIndex = 1, atttributeName?: string) {
path: v3/cypress/support/elements/table-tile.ts
user: login: kswenson
body: ```suggestion
  addNewAttribute(collectionIndex = 1, attributeName?: string) {
```
created_at: 2025-04-02T18:37:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,129 @@
+import clsx from "clsx"
+import { observer } from "mobx-react-lite"
+import React, { useEffect, useRef, useState } from "react"
+import { useMap } from "react-leaflet"
+import { IDataSet } from "../../../models/data/data-set"
+import { isSetCaseValuesAction } from "../../../models/data/data-set-actions"
+import { ICaseCreation } from "../../../models/data/data-set-types"
+import { insertCasesWithCustomUndoRedo } from "../../../models/data/data-set-undo"
+import { onAnyAction } from "../../../utilities/mst-utils"
+import { useDataDisplayLayout } from "../../data-display/hooks/use-data-display-layout"
+import PlacedLocationMarker from "../assets/placed-location-marker.svg"
+import { useMapModelContext } from "../hooks/use-map-model-context"
+import { kPinColors } from "../map-types"
+import { IMapPinLayerModel } from "../models/map-pin-layer-model"
+import { pinAttributesFromDataSet } from "../utilities/map-utils"
+import "./map-pin-layer.scss"
+import { selectCases, setSelectedCases } from "../../../models/data/data-set-utils"
path: v3/src/components/map/components/map-pin-layer.tsx
user: login: kswenson
body: Move import ⬆️ 
created_at: 2025-04-02T18:44:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,129 @@
+import clsx from "clsx"
+import { observer } from "mobx-react-lite"
+import React, { useEffect, useRef, useState } from "react"
+import { useMap } from "react-leaflet"
+import { IDataSet } from "../../../models/data/data-set"
+import { isSetCaseValuesAction } from "../../../models/data/data-set-actions"
+import { ICaseCreation } from "../../../models/data/data-set-types"
+import { insertCasesWithCustomUndoRedo } from "../../../models/data/data-set-undo"
+import { onAnyAction } from "../../../utilities/mst-utils"
+import { useDataDisplayLayout } from "../../data-display/hooks/use-data-display-layout"
+import PlacedLocationMarker from "../assets/placed-location-marker.svg"
+import { useMapModelContext } from "../hooks/use-map-model-context"
+import { kPinColors } from "../map-types"
+import { IMapPinLayerModel } from "../models/map-pin-layer-model"
+import { pinAttributesFromDataSet } from "../utilities/map-utils"
+import "./map-pin-layer.scss"
+import { selectCases, setSelectedCases } from "../../../models/data/data-set-utils"
+
+// FIX-ME: Can we get these numbers dynamically?
+const mapPinHeight = 35
+const mapPinWidth = 25
+
+interface IMapPinProps {
+  color?: string
+  dataset: IDataSet
+  id: string
+  key?: string
+  selected?: boolean
+  x: number
+  y: number
+}
+function MapPin({ color="#0068EA", dataset, id, selected, x, y }: IMapPinProps) {
+  const handleClick = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (e.shiftKey) {
+      selectCases([id], dataset, !selected)
+    } else {
+      setSelectedCases([id], dataset)
+    }
+  }
+
+  return (
+    <button
+      className={clsx("map-pin", { "selected-pin": selected })}
+      onClick={handleClick}
+      style={{ left: x, top: y }}
+    >
+      <PlacedLocationMarker color={color} />
+    </button>
+  )
+}
+
+interface IMapPinLayerProps {
+  mapLayerModel: IMapPinLayerModel
+}
+export const MapPinLayer = observer(function MapPinLayer({ mapLayerModel }: IMapPinLayerProps) {
+  const [_forceRerender, setForceRerender] = useState(0)
path: v3/src/components/map/components/map-pin-layer.tsx
user: login: kswenson
body: We have a `useForceUpdate()` hook which does the same thing:
```suggestion
  const forceUpdate = useForceUpdate()
```
created_at: 2025-04-02T18:47:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,129 @@
+import clsx from "clsx"
+import { observer } from "mobx-react-lite"
+import React, { useEffect, useRef, useState } from "react"
+import { useMap } from "react-leaflet"
+import { IDataSet } from "../../../models/data/data-set"
+import { isSetCaseValuesAction } from "../../../models/data/data-set-actions"
+import { ICaseCreation } from "../../../models/data/data-set-types"
+import { insertCasesWithCustomUndoRedo } from "../../../models/data/data-set-undo"
+import { onAnyAction } from "../../../utilities/mst-utils"
+import { useDataDisplayLayout } from "../../data-display/hooks/use-data-display-layout"
+import PlacedLocationMarker from "../assets/placed-location-marker.svg"
+import { useMapModelContext } from "../hooks/use-map-model-context"
+import { kPinColors } from "../map-types"
+import { IMapPinLayerModel } from "../models/map-pin-layer-model"
+import { pinAttributesFromDataSet } from "../utilities/map-utils"
+import "./map-pin-layer.scss"
+import { selectCases, setSelectedCases } from "../../../models/data/data-set-utils"
+
+// FIX-ME: Can we get these numbers dynamically?
+const mapPinHeight = 35
+const mapPinWidth = 25
+
+interface IMapPinProps {
+  color?: string
+  dataset: IDataSet
+  id: string
+  key?: string
+  selected?: boolean
+  x: number
+  y: number
+}
+function MapPin({ color="#0068EA", dataset, id, selected, x, y }: IMapPinProps) {
+  const handleClick = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (e.shiftKey) {
+      selectCases([id], dataset, !selected)
+    } else {
+      setSelectedCases([id], dataset)
+    }
+  }
+
+  return (
+    <button
+      className={clsx("map-pin", { "selected-pin": selected })}
+      onClick={handleClick}
+      style={{ left: x, top: y }}
+    >
+      <PlacedLocationMarker color={color} />
+    </button>
+  )
+}
+
+interface IMapPinLayerProps {
+  mapLayerModel: IMapPinLayerModel
+}
+export const MapPinLayer = observer(function MapPinLayer({ mapLayerModel }: IMapPinLayerProps) {
+  const [_forceRerender, setForceRerender] = useState(0)
+  const layerRef = useRef<HTMLDivElement>(null)
+  const map = useMap()
+  const mapModel = useMapModelContext()
+  const layout = useDataDisplayLayout()
+
+  const { dataConfiguration } = mapLayerModel
+  const dataset = dataConfiguration?.dataset
+  const { pinLatId, pinLongId } = dataset ? pinAttributesFromDataSet(dataset) : { pinLatId: "", pinLongId: "" }
+  const colorId = dataset?.attributes.find(attr => attr.type === "color")?.id
+
+  // Force a rerender when any relevant values change
+  useEffect(() => {
+    if (dataset) {
+      return onAnyAction(dataset, action => {
+        if (isSetCaseValuesAction(action)) {
+          setForceRerender((prev) => prev + 1)
+        }
+      })
+    }
+  }, [dataset])
+
+  // Force a rerender when the map is resized, panned, or zoomed
+  const { contentWidth: _contentWidth, contentHeight: _contentHeight } = layout
+  const { center: _center, zoom: _zoom } = mapModel.leafletMapState
+
+  const handleClick = (e: React.MouseEvent) => {
+    if (mapLayerModel.addMode) {
+      e.stopPropagation()
+      mapLayerModel.setAddMode(false)
+
+      const layerBB = layerRef.current?.getBoundingClientRect()
+      if (!layerBB || !dataset) return
+      const { lat, lng } = map.containerPointToLatLng([e.clientX - layerBB.x, e.clientY - layerBB.y])
+      const color = kPinColors[(dataset.items.length ?? 0) % kPinColors.length]
+      const newItem: ICaseCreation = { [pinLatId]: lat, [pinLongId]: lng }
+      if (colorId) newItem[colorId] = color
+      insertCasesWithCustomUndoRedo(dataset, [newItem])
+    }
+  }
+
+  const renderPins = mapLayerModel.isVisible
+  return (
+    <div
+      className={clsx("map-pin-layer", { "add-mode": mapLayerModel.addMode })}
+      onClick={handleClick}
+      ref={layerRef}
+    >
+      {renderPins && dataset?.items.map(({ __id__ }, index) => {
+        const lat = Number(dataset.getValue(__id__, pinLatId))
+        const long = Number(dataset.getValue(__id__, pinLongId))
path: v3/src/components/map/components/map-pin-layer.tsx
user: login: kswenson
body: ```suggestion
        const lat = dataset.getNumeric(__id__, pinLatId)
        const long = dataset.getNumeric(__id__, pinLongId)
```
created_at: 2025-04-02T18:51:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,129 @@
+import clsx from "clsx"
+import { observer } from "mobx-react-lite"
+import React, { useEffect, useRef, useState } from "react"
+import { useMap } from "react-leaflet"
+import { IDataSet } from "../../../models/data/data-set"
+import { isSetCaseValuesAction } from "../../../models/data/data-set-actions"
+import { ICaseCreation } from "../../../models/data/data-set-types"
+import { insertCasesWithCustomUndoRedo } from "../../../models/data/data-set-undo"
+import { onAnyAction } from "../../../utilities/mst-utils"
+import { useDataDisplayLayout } from "../../data-display/hooks/use-data-display-layout"
+import PlacedLocationMarker from "../assets/placed-location-marker.svg"
+import { useMapModelContext } from "../hooks/use-map-model-context"
+import { kPinColors } from "../map-types"
+import { IMapPinLayerModel } from "../models/map-pin-layer-model"
+import { pinAttributesFromDataSet } from "../utilities/map-utils"
+import "./map-pin-layer.scss"
+import { selectCases, setSelectedCases } from "../../../models/data/data-set-utils"
+
+// FIX-ME: Can we get these numbers dynamically?
+const mapPinHeight = 35
+const mapPinWidth = 25
path: v3/src/components/map/components/map-pin-layer.tsx
user: login: kswenson
body: I'm not aware of a good way to do so.
created_at: 2025-04-02T18:55:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,49 @@
+.pin-controls {
+  display: flex;
+  flex-direction: column;
+  left: 10px;
+  position: absolute;
+  top: 90px;
+  // FIX-ME: Why does this need to be >= 10?
+  z-index: 11;
path: v3/src/components/map/components/pin-controls.scss
user: login: kswenson
body: Map z-index issues are subtle because of interaction with Leaflet, etc. It'd be great to have someone take the time to figure it out and clean it up, but that time probably isn't now.
created_at: 2025-04-02T18:57:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,27 @@
+.map-pin-layer {
+  height: 100%;
+  position: absolute;
+  width: 100%;
+  // FIX-ME: Why does this need to be >= 10?
+  z-index: 10;
path: v3/src/components/map/components/map-pin-layer.scss
user: login: kswenson
body: Map z-index issues are subtle because of interaction with Leaflet, etc. It'd be great to have someone take the time to figure it out and clean it up, but that time probably isn't now.
created_at: 2025-04-02T18:58:03Z
author_association: MEMBER
diff_hunk: @@ -46,7 +47,15 @@ export const
 
 
   kLatNames = ['latitude', 'lat', 'latitud', 'breitengrad', '緯度', 'عرض جغرافیایی'],
-  kLongNames = ['longitude', 'long', 'lng', 'lon', 'longitud', 'längengrad', '経度', 'طول جغرافیایی']
+  kLongNames = ['longitude', 'long', 'lng', 'lon', 'longitud', 'längengrad', '経度', 'طول جغرافیایی'],
+
+  kPinLatNames = ['pinlatitude', 'pinlat', 'pinlatitud', 'pinbreitengrad', 'pin緯度', 'pinعرض جغرافیایی'],
+  kPinLongNames = [
+    'pinlongitude', 'pinlong', 'pinlng', 'pinlon', 'pinlongitud', 'pinlängengrad', 'pin経度', 'pinطول جغرافیایی'
+  ],
path: v3/src/components/map/map-types.ts
user: login: kswenson
body: That's probably not the right solution for languages like Chinese that use a different alphabet, but it's probably the best we can do right now. Ultimately, this should be integrated into the rest of our localization system, but that's a project for another day. Maybe for now add a TODO comment about figuring out a better solution.
created_at: 2025-04-02T19:04:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,63 @@
+import {Instance, SnapshotIn, types} from "mobx-state-tree"
+import {IDataSet} from "../../../models/data/data-set"
+import {getSharedCaseMetadataFromDataset} from "../../../models/shared/shared-data-utils"
+import {computePointRadius} from "../../data-display/data-display-utils"
+import {IDataDisplayLayerModel} from "../../data-display/models/data-display-layer-model"
+import {kMapPinLayerType} from "../map-types"
+import {latLongAttributesFromDataSet} from "../utilities/map-utils"
+import {MapGridModel} from "./map-grid-model"
+import {MapLayerModel} from "./map-layer-model"
+
+export const MapPointDisplayTypes = ["points", "heatmap"] as const
+export type MapPointDisplayType = typeof MapPointDisplayTypes[number]
+export function isMapPointDisplayType(value: any): value is MapPointDisplayType {
+  return MapPointDisplayTypes.includes(value)
+}
path: v3/src/components/map/models/map-pin-layer-model.ts
user: login: kswenson
body: ```suggestion
```
Don't think you need these here. Seems like copy/paste detritus.
created_at: 2025-04-02T19:08:16Z
author_association: MEMBER
diff_hunk: @@ -57,6 +61,12 @@ export const MapInterior = observer(function MapInterior({setPixiPointsLayer}: I
   return (
     <>
       {renderMapLayerComponents()}
+      {pinLayer && (
path: v3/src/components/map/components/map-interior.tsx
user: login: bfinzer
body: Is there a reason not to treat any pin layer just as point and polygon layers are treated? (One could sort layers to make sure any pin layer is always on top.)
created_at: 2025-04-02T21:55:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,7 +47,16 @@ export const
 
 
   kLatNames = ['latitude', 'lat', 'latitud', 'breitengrad', '緯度', 'عرض جغرافیایی'],
-  kLongNames = ['longitude', 'long', 'lng', 'lon', 'longitud', 'längengrad', '経度', 'طول جغرافیایی']
+  kLongNames = ['longitude', 'long', 'lng', 'lon', 'longitud', 'längengrad', '経度', 'طول جغرافیایی'],
+
+  // TODO: Figure out a better names for these in languages that don't use the latin alphabet.
path: v3/src/components/map/map-types.ts
user: login: bfinzer
body: Actually, since these are user-visible strings, the word 'pin' should be translated for all the non-English names. That should at least be noted in the TODO.
created_at: 2025-04-02T22:10:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-01T23:11:59Z
body: ## [Codecov](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) Report
Attention: Patch coverage is `89.53488%` with `18 lines` in your changes missing coverage. Please review.
> Project coverage is 86.31%. Comparing base [(`1a59f4b`)](https://app.codecov.io/gh/concord-consortium/codap/commit/1a59f4b4f92e373669ad913b4ebd31a58be96155?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) to head [(`b01d2d7`)](https://app.codecov.io/gh/concord-consortium/codap/commit/b01d2d745f85fd8cec24864973543fa1fada95e4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium).
> Report is 5 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) | Patch % | Lines |
|---|---|---|
| [...3/src/components/map/models/map-pin-layer-model.ts](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?src=pr&el=tree&filepath=v3%2Fsrc%2Fcomponents%2Fmap%2Fmodels%2Fmap-pin-layer-model.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium#diff-djMvc3JjL2NvbXBvbmVudHMvbWFwL21vZGVscy9tYXAtcGluLWxheWVyLW1vZGVsLnRz) | 62.06% | [11 Missing :warning: ](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) |
| [v3/src/components/map/models/map-content-model.ts](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?src=pr&el=tree&filepath=v3%2Fsrc%2Fcomponents%2Fmap%2Fmodels%2Fmap-content-model.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium#diff-djMvc3JjL2NvbXBvbmVudHMvbWFwL21vZGVscy9tYXAtY29udGVudC1tb2RlbC50cw==) | 77.77% | [4 Missing :warning: ](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) |
| [v3/src/components/map/components/map-pin-layer.tsx](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?src=pr&el=tree&filepath=v3%2Fsrc%2Fcomponents%2Fmap%2Fcomponents%2Fmap-pin-layer.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium#diff-djMvc3JjL2NvbXBvbmVudHMvbWFwL2NvbXBvbmVudHMvbWFwLXBpbi1sYXllci50c3g=) | 97.01% | [2 Missing :warning: ](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) |
| [v3/src/components/map/utilities/map-utils.ts](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?src=pr&el=tree&filepath=v3%2Fsrc%2Fcomponents%2Fmap%2Futilities%2Fmap-utils.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium#diff-djMvc3JjL2NvbXBvbmVudHMvbWFwL3V0aWxpdGllcy9tYXAtdXRpbHMudHM=) | 95.00% | [1 Missing :warning: ](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1892      +/-   ##
==========================================
+ Coverage   86.28%   86.31%   +0.03%     
==========================================
  Files         673      676       +3     
  Lines       34947    35113     +166     
  Branches     9274     9964     +690     
==========================================
+ Hits        30155    30309     +154     
+ Misses       4634     4456     -178     
- Partials      158      348     +190     
```

| [Flag](https://app.codecov.io/gh/concord-consortium/codap/pull/1892/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) | Coverage Δ | |
|---|---|---|
| [cypress](https://app.codecov.io/gh/concord-consortium/codap/pull/1892/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) | `72.91% <87.87%> (+0.08%)` | :arrow_up: |
| [jest](https://app.codecov.io/gh/concord-consortium/codap/pull/1892/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium) | `56.08% <28.57%> (-0.14%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/concord-consortium/codap/pull/1892?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=concord-consortium).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: cypress[bot]
created_at: 2025-04-01T23:12:04Z
body: 
<h3>
  <a href="https://cloud.cypress.io/projects/msrfxa?utm_source=github&utm_medium=PASSED&utm_campaign=project">codap-v3</a>&nbsp;
  <img src="https://assets.cypress.io/github-pr-comment-icons/chevron.svg" alt="" height="14" width="14" />&nbsp;
  <a href="https://cloud.cypress.io/projects/msrfxa/runs/6912/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">Run #6912</a>
</h3>

<details>
  <summary>
    <strong>Run Properties:</strong>&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/msrfxa/runs/6912/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#6912</a></code> &nbsp;&bull;&nbsp;
    <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/concord-consortium/codap/commit/bc502b836ea5e502bf6bceb5c486f4b941889451">bc502b836e</a>: Codap-6 Map Pins (#1892)</code>
  </summary><br />

  <table width="744">
    <tbody>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Project</strong>
        </td>
        <td width="520">
          <code><a href="https://cloud.cypress.io/projects/msrfxa?utm_source=github&utm_medium=PASSED&utm_campaign=project">codap-v3</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td width="224">
          <strong>Branch Review</strong>
        </th>
        <td>
          <code><a href="https://cloud.cypress.io/projects/msrfxa/branches/main/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=1892">main</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run status</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="status check passed" height="10" width="10" />&nbsp;Passed&nbsp;<a href="https://cloud.cypress.io/projects/msrfxa/runs/6912/overview?utm_source=github&utm_medium=PASSED&utm_campaign=run%20number">#6912</a></code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Run duration</strong></td>
        <td><code>06m 25s</code></td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Commit</strong></td>
        <td>
          <code><img src="https://assets.cypress.io/github-pr-comment-icons/git-commit.svg" alt="git commit" width="10" height="10" /> <a href="https://github.com/concord-consortium/codap/commit/bc502b836ea5e502bf6bceb5c486f4b941889451">bc502b836e</a>: Codap-6 Map Pins (#1892)</code>
        </td>
      </tr>
      <tr />
      <tr height="48">
        <td><strong>Committer</strong></td>
        <td><code>Teale Fristoe</code></td>
      </tr>
    </tbody>
    <tfoot>
      <tr />
      <tr height="48">
        <td colspan="2">
          <a href="https://cloud.cypress.io/projects/msrfxa/runs/6912/properties?utm_source=github&utm_medium=PASSED&utm_campaign=properties">View all properties for this run ↗︎</a>
        </td>
      </tr>
    </tfoot>
  </table>
  <hr />
</details>

<table width="744">
  <thead>
    <tr />
    <tr height="48">
      <th colspan="2" align="left" width="744">Test results</th>
    </tr>
  </thead>
  <tbody>
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/failed.svg" alt="Tests that failed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/6912/overview?reviewViewBy=FAILED&utm_source=github&utm_medium=PASSED&utm_campaign=failed%20tests">
          <strong>Failures</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/flaky-v3.svg" alt="Tests that were flaky" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/6912/overview?reviewViewBy=FLAKY&utm_source=github&utm_medium=PASSED&utm_campaign=flaky%20tests">
          <strong>Flaky</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/pending.svg" alt="Tests that did not run due to a developer annotating a test with .skip" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/6912/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=pending%20tests&statuses=%5B%7B%22value%22%3A%22PENDING%22%2C%22label%22%3A%22PENDING%22%7D%5D">
          <strong>Pending</strong>
        </a>
      </td>
      <td>
        <code><strong>50</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/skipped-v3.svg" alt="Tests that did not run due to a failure in a mocha hook" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/6912/test-results?utm_source=github&utm_medium=PASSED&utm_campaign=skipped%20tests&statuses=%5B%7B%22value%22%3A%22SKIPPED%22%2C%22label%22%3A%22SKIPPED%22%7D%5D">
          <strong>Skipped</strong>
        </a>
      </td>
      <td>
        <code><strong>0</strong></code>
      </td>
    </tr>
    <tr />
    <tr height="48">
      <td width="224">
        <img src="https://assets.cypress.io/github-pr-comment-icons/passed.svg" alt="Tests that passed" width="10" height="10" />&nbsp;
        <a href="https://cloud.cypress.io/projects/msrfxa/runs/6912/overview?utm_source=github&utm_medium=PASSED&utm_campaign=passed%20tests">
          <strong>Passing</strong>
        </a>
      </td>
      <td>
        <code><strong>251</strong></code>
      </td>
    </tr><tfoot>
    <tr height="48"><td colspan="2"><a href="https://cloud.cypress.io/projects/msrfxa/branches/main/review?utm_source=github&utm_medium=PASSED&utm_campaign=review%20changes&changeRequest=1892">View all changes introduced in this branch ↗︎</a></td></tr></tfoot>
  </tbody>
</table>






author_association: NONE
user: login: bfinzer
created_at: 2025-04-02T22:06:29Z
body: I renamed a `pinLat` attribute to `pinLatX` and got a runtime error. Probably you are looking up by name when you should be using an ID? (I got worried that regular points would misbehave in the same way, but they don't.)
author_association: CONTRIBUTOR
user: login: tealefristoe
created_at: 2025-04-02T22:25:49Z
body: @bfinzer Good catch on that crash bug. I tried it with points, and you're right that it doesn't break immediately when you change the name, but it does crash if you change a value after you change the name. You'll also see the same crash if you delete either the `lat` or `long` attribute.

I'm not sure the best approach for fixing this problem. I could store the id of the attributes when they're first discovered, which would fix any name changes. But it wouldn't fix the issue if one of the attributes was deleted. Another approach would be to stop rendering the pins if one of the special attributes changes. I think this would be pretty easy to implement for pins, but I'm not sure how easy this would be for points.
author_association: CONTRIBUTOR
user: login: tealefristoe
created_at: 2025-04-03T03:30:59Z
body: @bfinzer I went ahead and disabled the pin layer if the dataset it's using stops having the necessary attributes (they're renamed or deleted). This seems to work pretty well. It would probably be good to make bug stories for the equivalent point layer problems, and we should check the boundary layer as well.
author_association: CONTRIBUTOR