Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/app/chart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -344,12 +344,19 @@ export default function ChartPage() {

// Update cart in session storage when user changes cart
useEffect(() => {
sessionStorage.setItem("cartStorage", JSON.stringify(cart));
if (cart.length !== 0) {
sessionStorage.setItem("cartStorage", JSON.stringify(cart));
}
}, [cart]);

// Update cart names when use changes the filters
useEffect(() => {
sessionStorage.setItem("cartNameStorage", JSON.stringify(filterNames));
if (filterNames.length !== 0) {
sessionStorage.setItem(
"cartNameStorage",
JSON.stringify(filterNames),
);
}
}, [filterNames]);

// Sync tempYearRange with yearRange only when popover opens in custom mode
Expand Down
262 changes: 243 additions & 19 deletions src/app/map/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,25 @@
*
* src/app/heat-map/page.tsx
*
* Author: Anne, Chiara & Elki, Steven
* Author: Anne, Chiara & Elki, Steven, Will
* Last updated: 2/14/26
*
* Summary: Heatmap + Clusters within MA region
*
**************************************************************/

import { Map } from "@/components/ui/map";
import { Suspense, useEffect, useState, useRef } from "react";
import { Suspense, useEffect, useState, useRef, useMemo } from "react";
import { toast } from "sonner";
import { Loader2, Link, Share } from "lucide-react";

// queryStates required for URL sharing with nuqs
import { useQueryState, parseAsInteger, parseAsString } from "nuqs";
import {
useQueryState,
parseAsInteger,
parseAsString,
parseAsBoolean,
} from "nuqs";

const VALID_METRICS = ["Students", "Projects", "Teachers"];

Expand All @@ -27,6 +32,64 @@ import CountDropdown from "@/components/CountDropdown";
import { Button } from "@/components/ui/button";
import { exportMapToPDF } from "@/lib/heatmap-export";
import { useHeatmapLayers } from "@/hooks/useHeatmapLayers";
import { Cart } from "@/components/Cart";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { PlusCircle } from "lucide-react";

type Region = {
center: [number, number];
zoom: number;
// Restrict zoom to stay on MA approximately
maxZoom: number;
minZoom: number;
};

const regions: Record<string, Region> = {
Default: {
center: [-71.7, 42.2],
zoom: 7,
maxZoom: 24,
minZoom: 7,
},
Western: {
center: [-73.2, 42.3],
zoom: 8,
maxZoom: 24,
minZoom: 7,
},

Central: {
center: [-72.0, 42.3],
zoom: 8,
maxZoom: 24,
minZoom: 7,
},

Boston: {
center: [-71.1, 42.35],
zoom: 9,
maxZoom: 24,
minZoom: 8,
},

Northeast: {
center: [-70.9, 42.6],
zoom: 9,
maxZoom: 24,
minZoom: 8,
},

Southeast: {
center: [-70.9, 42.0],
zoom: 8,
maxZoom: 24,
minZoom: 8,
},
};

function HeatMapPage() {
const [schoolPoints, setSchoolPoints] =
Expand All @@ -45,6 +108,17 @@ function HeatMapPage() {
parseAsString.withDefault("Projects"),
);

// gateway school toggle variable
const [onlyGatewaySchools, setOnlyGatewaySchools] = useQueryState(
"onlyGatewaySchools",
parseAsBoolean.withDefault(false),
);

const [regionView, setregionView] = useQueryState(
"regionView",
parseAsString.withDefault("Default"),
);

// Validate query params during render
const currentYear = new Date().getFullYear();
const year = rawYear > currentYear || rawYear < 1990 ? 2025 : rawYear;
Expand All @@ -68,6 +142,22 @@ function HeatMapPage() {
}
};

const [gatewaySchools, setGatewaySchools] = useState<string[]>([]);

// Fetch gateway schools
useEffect(() => {
fetch("/api/schools?gateway=true&list=true")
.then((res) => res.json())
.then((data) => {
const schoolNames: string[] = data.map(
(school: { name: string }) => school.name,
);

setGatewaySchools(schoolNames);
})
.catch(() => toast.error("Failed to load gateway schools"));
}, []);

// Fetch school point data for heat layer
useEffect(() => {
const controller = new AbortController();
Expand All @@ -91,22 +181,74 @@ function HeatMapPage() {
return () => controller.abort();
}, [year]);

useHeatmapLayers({ mapRef, schoolPoints, metric, showSchools });
// Filter school points based on the gateway toggle
const filteredSchoolPoints = useMemo(() => {
if (!schoolPoints) return null;
if (!onlyGatewaySchools) return schoolPoints;

return {
...schoolPoints,
features: schoolPoints.features.filter((feature) =>
gatewaySchools.includes(feature.properties?.name),
),
};
}, [schoolPoints, onlyGatewaySchools]);

useHeatmapLayers({ mapRef, filteredSchoolPoints, metric, showSchools });

useEffect(() => {
if (!mapRef.current) {
return;
}
const map = mapRef.current;
map?.flyTo({
center: regions[regionView].center,
zoom: regions[regionView].zoom,
essential: true,
});
}, [regionView]);

const [cart, setCart] = useState<string[]>([]);

const [filterNames, setFilterNames] = useState<string[]>([]);

useEffect(() => {
const cartStorage = sessionStorage.getItem("cartStorage");
const cartNameStorage = sessionStorage.getItem("cartNameStorage");

if (cartStorage) {
setCart(JSON.parse(cartStorage));
}

if (cartNameStorage) {
setFilterNames(JSON.parse(cartNameStorage));
}
}, []);

// Update cart in session storage when user changes cart
useEffect(() => {
if (cart.length !== 0) {
sessionStorage.setItem("cartStorage", JSON.stringify(cart));
}
}, [cart]);

// Update cart names when use changes the filters
useEffect(() => {
if (filterNames.length !== 0) {
sessionStorage.setItem(
"cartNameStorage",
JSON.stringify(filterNames),
);
}
}, [filterNames]);

const filterName = `Heatmap - ${metric} ${onlyGatewaySchools ? " at Gateway Schools" : ""} in ${regionView === "Default" ? "MA" : regionView + ` Region `} (${year})`;

return (
<div className="flex p-4 flex-col h-screen w-full justify-center">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl py-4 font-semibold">Heatmap</h1>
<h1 className="text-2xl py-4 font-semibold">{filterName}</h1>
<div className="flex gap-3">
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
onClick={copyURLtoClipboard}
>
<Link className="w-4 h-4" />
Share
</Button>
<Button
variant="outline"
size="sm"
Expand All @@ -115,12 +257,60 @@ function HeatMapPage() {
const mapCurrent = mapRef.current;
if (!mapCurrent) return;
// Call the heatmap export function
exportMapToPDF(mapCurrent);
exportMapToPDF(mapCurrent, filterName);
}}
>
<Share className="w-4 h-4" />
Export
</Button>
<HoverCard>
<HoverCardTrigger
delay={10}
closeDelay={100}
render={
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
onClick={() => {
const map = mapRef.current;
if (!map) return;
const mapImageData = map
.getCanvas()
.toDataURL("image/jpeg", 0.5);
setCart([...cart, mapImageData]);
setFilterNames([
...filterNames,
filterName,
]);
}}
>
<PlusCircle className="w-4 h-4" />
Add to
</Button>
}
/>
<HoverCardContent
className="flex flex-col gap-0.5 mt-2"
align="end"
>
<Cart
filterNames={filterNames}
cart={cart}
setCart={setCart}
setFilterNames={setFilterNames}
/>
</HoverCardContent>
</HoverCard>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
onClick={copyURLtoClipboard}
>
<Link className="w-4 h-4" />
Share
</Button>
</div>
</div>
<div className="flex flex-row justify-between items-end gap-4 shrink-0 pb-5">
Expand All @@ -132,6 +322,7 @@ function HeatMapPage() {
<CountDropdown
selectedCount={metric}
onCountChange={setMetric}
options={["Students", "Projects", "Teachers"]}
/>
</div>
<div className="flex flex-col gap-1.5 w-48">
Expand All @@ -144,7 +335,40 @@ function HeatMapPage() {
onYearChange={setYear}
/>
</div>
<div className="flex flex-col gap-1.5 w-48">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">
Region View
</label>
<CountDropdown
selectedCount={regionView}
onCountChange={setregionView}
options={Object.keys(regions)}
/>
</div>
<div className="flex flex-col gap-1.5 w-48">
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider pl-1">
Filters
</label>
<div className="flex items-center h-10 px-2">
<input
id="gateway-toggle"
type="checkbox"
className="w-4 h-4 cursor-pointer rounded border-slate-300"
checked={onlyGatewaySchools}
onChange={(e) =>
setOnlyGatewaySchools(e.target.checked)
}
/>
<label
htmlFor="gateway-toggle"
className="ml-2 text-sm cursor-pointer select-none"
>
Gateway Schools Only
</label>
</div>
</div>
</div>

<Button
onClick={() => setShowSchools(!showSchools)}
className="w-32 py-2"
Expand All @@ -154,11 +378,11 @@ function HeatMapPage() {
</div>
<div className="flex-1 rounded-2xl overflow-hidden border border-slate-200 relative">
<Map
center={[-71.7, 42.2]}
zoom={7}
center={regions[regionView].center}
zoom={regions[regionView].zoom}
// Restrict zoom to stay on MA approximately
maxZoom={24}
minZoom={7}
maxZoom={regions[regionView].maxZoom}
minZoom={regions[regionView].minZoom}
// Restrict canvas to stay on MA approximately
maxBounds={[
[-74.5, 40.2],
Expand Down
4 changes: 2 additions & 2 deletions src/components/CountDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ import {
type CountDropdownProps = {
selectedCount?: string;
onCountChange?: (year: string) => void;
options: string[];
};

const options = ["Students", "Projects", "Teachers"];

export default function CountDropdown({
selectedCount,
onCountChange,
options,
}: CountDropdownProps) {
const [toCount, setToCount] = useState(selectedCount);

Expand Down
Loading