analytica-frontend-lib
Version:
Repositório público dos componentes utilizados nas plataformas da Analytica Ensino
623 lines (621 loc) • 21.2 kB
JavaScript
import {
Alert_default
} from "./chunk-YQTOSHD5.mjs";
import {
useTheme
} from "./chunk-HYVBZ5EV.mjs";
import {
Text_default
} from "./chunk-IMCIR6TJ.mjs";
import {
cn
} from "./chunk-53ICLDGS.mjs";
// src/components/ChoroplethMap/ChoroplethMap.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GoogleMap, useJsApiLoader } from "@react-google-maps/api";
import union from "@turf/union";
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
var GOOGLE_MAPS_LOADER_ID = "google-maps-script";
var TOOLTIP_PROFILE_LINES = [
{ key: "students", label: "Estudantes" },
{ key: "teachers", label: "Professores(as)" },
{ key: "managers", label: "Diretores(as)" }
];
var FADE_DURATION = 400;
var HOVER_DURATION = 200;
var TARGET_OPACITY = 0.8;
var getCssVar = (name, fallback = "") => {
if (typeof document === "undefined") return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
};
var getColorClasses = () => [
{
min: 0.75,
max: 1.01,
fillColor: getCssVar("--color-map-highlight", "#66b584"),
strokeColor: getCssVar("--color-map-highlight", "#66b584"),
label: "Destaque (75% com acesso)"
},
{
min: 0.5,
max: 0.75,
fillColor: getCssVar("--color-map-above-avg", "#f8cc2e"),
strokeColor: getCssVar("--color-map-above-avg", "#f8cc2e"),
label: "Acima da m\xE9dia (50 at\xE9 74% com acesso)"
},
{
min: 0.25,
max: 0.5,
fillColor: getCssVar("--color-map-below-avg", "#fb954b"),
strokeColor: getCssVar("--color-map-below-avg", "#fb954b"),
label: "Abaixo da m\xE9dia (25 at\xE9 49% com acesso)"
},
{
min: 0.01,
max: 0.25,
fillColor: getCssVar("--color-map-attention", "#b91c1c"),
strokeColor: getCssVar("--color-map-attention", "#b91c1c"),
label: "Ponto de aten\xE7\xE3o (Abaixo de 25% com acesso)"
},
{
min: -0.01,
max: 0.01,
fillColor: getCssVar("--color-map-no-access", "#2f2f2f"),
strokeColor: getCssVar("--color-map-no-access", "#2f2f2f"),
label: "Sem acesso (0%)"
}
];
var getColorClass = (value, colorClasses) => {
for (const colorClass of colorClasses) {
if (value >= colorClass.min && value < colorClass.max) {
return colorClass;
}
}
return colorClasses[0];
};
var computeNREBoundaries = (data) => {
const groups = /* @__PURE__ */ new Map();
data.forEach((region) => {
const groupKey = region.groupName ?? region.name;
const existing = groups.get(groupKey) ?? [];
existing.push(region);
groups.set(groupKey, existing);
});
const boundaries = [];
groups.forEach((regions) => {
let merged = null;
for (const region of regions) {
if (region.geoJson.type !== "Feature") continue;
const feature = region.geoJson;
if (merged) {
const result = union({
type: "FeatureCollection",
features: [merged, feature]
});
if (result) merged = result;
} else {
merged = feature;
}
}
if (merged) boundaries.push(merged);
});
return boundaries;
};
var createStyleFunction = (opacity, colorClasses, strokeCityColor, unmanagedFillColor) => {
return (feature) => {
if (feature.getProperty("regionIsManaged") === false) {
return {
fillColor: unmanagedFillColor,
fillOpacity: opacity,
strokeColor: strokeCityColor,
strokeWeight: 0.3,
cursor: "default"
};
}
const value = feature.getProperty("regionValue");
const colorClass = getColorClass(value ?? 0, colorClasses);
return {
fillColor: colorClass.fillColor,
fillOpacity: opacity,
strokeColor: strokeCityColor,
strokeWeight: 0.3,
cursor: "pointer"
};
};
};
var containerStyle = {
width: "100%",
height: "415px",
borderRadius: "0.5rem"
};
var defaultCenter = {
lat: -24.7,
lng: -51.5
};
var LegendItem = ({
color,
label,
borderColor,
active = true,
onClick
}) => /* @__PURE__ */ jsxs(
Text_default,
{
as: "button",
type: "button",
"aria-pressed": active,
className: "flex items-center gap-2 cursor-pointer transition-opacity duration-200",
style: { opacity: active ? 1 : 0.4 },
onClick,
children: [
/* @__PURE__ */ jsx(
"div",
{
className: "w-3 h-3 rounded-full",
style: {
backgroundColor: color,
border: borderColor ? `1px solid ${borderColor}` : "none"
}
}
),
/* @__PURE__ */ jsx(Text_default, { as: "span", size: "sm", weight: "medium", color: "text-text-600", children: label })
]
}
);
var LoadingSkeleton = () => /* @__PURE__ */ jsx("div", { className: "w-full h-full flex items-center justify-center bg-background-50 rounded-lg animate-pulse", children: /* @__PURE__ */ jsx(Text_default, { size: "sm", color: "text-text-400", children: "Carregando mapa..." }) });
var ChoroplethMap = ({
data,
apiKey,
title = "Performance por regi\xE3o",
countLabel = "Acessos",
loading = false,
bounds,
onRegionClick,
headerAction,
infoText,
activeProfile,
className
}) => {
const mapId = GOOGLE_MAPS_LOADER_ID;
const [map, setMap] = useState(null);
const [hoveredRegion, setHoveredRegion] = useState(null);
const [infoPosition, setInfoPosition] = useState(null);
const [activeClasses, setActiveClasses] = useState(
/* @__PURE__ */ new Set([0, 1, 2, 3, 4])
);
const fadeAnimationRef = useRef(null);
const hoverAnimationsRef = useRef(/* @__PURE__ */ new Map());
const nreBoundaryLayerRef = useRef(null);
const onRegionClickRef = useRef(onRegionClick);
onRegionClickRef.current = onRegionClick;
const { isDark } = useTheme();
const dataSignature = useMemo(
() => data.map((d) => {
const b = d.accessBreakdown;
const breakdown = b ? `${b.students.withAccess}/${b.students.withoutAccess}/${b.teachers.withAccess}/${b.teachers.withoutAccess}/${b.managers.withAccess}/${b.managers.withoutAccess}` : "";
return `${d.id}:${d.value}:${d.name}:${d.groupName ?? ""}:${d.accessCount}:${d.isManagedRegion === false ? 0 : 1}:${breakdown}`;
}).join("|"),
[data]
);
const stableData = useMemo(() => data, [dataSignature]);
const colorClasses = useMemo(() => getColorClasses(), [isDark]);
const mapOptions = useMemo(() => {
const bgColor = getCssVar("--color-background-50", "#F6F6F6");
return {
disableDefaultUI: true,
zoomControl: true,
scrollwheel: true,
draggable: true,
backgroundColor: bgColor,
styles: [
{
stylers: [{ color: bgColor }]
},
{
elementType: "labels",
stylers: [{ visibility: "off" }]
},
{
elementType: "geometry.stroke",
stylers: [{ visibility: "off" }]
}
]
};
}, [isDark]);
const { isLoaded, loadError } = useJsApiLoader({
id: mapId,
googleMapsApiKey: apiKey
});
const onLoad = useCallback(
(mapInstance) => {
setMap(mapInstance);
if (bounds) {
const googleBounds = new google.maps.LatLngBounds(
{ lat: bounds.south, lng: bounds.west },
{ lat: bounds.north, lng: bounds.east }
);
mapInstance.fitBounds(googleBounds, 20);
google.maps.event.addListenerOnce(mapInstance, "idle", () => {
const view = mapInstance.getBounds();
const zoom = mapInstance.getZoom();
if (!view || zoom == null) return;
const viewSpanLat = view.getNorthEast().lat() - view.getSouthWest().lat();
const viewSpanLng = view.getNorthEast().lng() - view.getSouthWest().lng();
const targetSpanLat = bounds.north - bounds.south;
const targetSpanLng = bounds.east - bounds.west;
if (targetSpanLat <= 0 || targetSpanLng <= 0) return;
const extraZoom = Math.min(
Math.log2(viewSpanLat / targetSpanLat),
Math.log2(viewSpanLng / targetSpanLng)
) - 0.05;
if (extraZoom > 0) {
mapInstance.setZoom(zoom + Math.min(extraZoom, 1));
}
});
}
},
[bounds]
);
const onUnmount = useCallback(() => {
if (nreBoundaryLayerRef.current) {
nreBoundaryLayerRef.current.setMap(null);
}
setMap(null);
}, []);
const nreBoundaries = useMemo(
() => computeNREBoundaries(stableData),
[stableData]
);
useEffect(() => {
if (!map || !stableData.length) return;
const strokeCityColor = getCssVar("--color-map-stroke-city", "#ffffff");
const unmanagedFillColor = getCssVar(
"--color-map-unmanaged-region",
"#e0e0e0"
);
const strokeNreColor = getCssVar("--color-map-stroke-nre", "#ffffff");
map.data.forEach((feature) => {
map.data.remove(feature);
});
if (nreBoundaryLayerRef.current) {
nreBoundaryLayerRef.current.setMap(null);
nreBoundaryLayerRef.current = null;
}
stableData.forEach((region) => {
try {
const feature = map.data.addGeoJson(region.geoJson);
if (feature && feature.length > 0) {
feature.forEach((f) => {
f.setProperty("regionId", region.id);
f.setProperty("regionName", region.name);
f.setProperty("regionValue", region.value);
f.setProperty("regionAccessCount", region.accessCount);
f.setProperty("regionIsManaged", region.isManagedRegion !== false);
});
}
} catch (error) {
console.error(
`Failed to add GeoJSON for region ${region.name}:`,
error
);
}
});
const cityFeatureIndex = /* @__PURE__ */ new Map();
map.data.forEach((f) => {
const id = f.getProperty("regionId");
if (id) {
const list = cityFeatureIndex.get(id) ?? [];
list.push(f);
cityFeatureIndex.set(id, list);
}
});
map.data.setStyle(
createStyleFunction(0, colorClasses, strokeCityColor, unmanagedFillColor)
);
const nreLayer = new google.maps.Data();
nreBoundaries.forEach((boundary) => {
nreLayer.addGeoJson(boundary);
});
nreLayer.setStyle({
fillOpacity: 0,
strokeColor: strokeNreColor,
strokeWeight: 1,
clickable: false
});
nreLayer.setMap(map);
nreBoundaryLayerRef.current = nreLayer;
const startTime = performance.now();
const animateFadeIn = (currentTime) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / FADE_DURATION, 1);
const currentOpacity = progress * TARGET_OPACITY;
map.data.setStyle(
createStyleFunction(
currentOpacity,
colorClasses,
strokeCityColor,
unmanagedFillColor
)
);
if (progress < 1) {
fadeAnimationRef.current = requestAnimationFrame(animateFadeIn);
} else {
fadeAnimationRef.current = null;
}
};
fadeAnimationRef.current = requestAnimationFrame(animateFadeIn);
const applyHoverStyle = (features, opacity, weight) => {
features.forEach((f) => {
map.data.overrideStyle(f, {
fillOpacity: opacity,
strokeColor: strokeCityColor,
strokeWeight: weight
});
});
};
const animateHover = (groupKey, features, from, to, fromWeight, toWeight) => {
const running = hoverAnimationsRef.current.get(groupKey);
if (running) {
cancelAnimationFrame(running);
}
const start = performance.now();
const animate = (now) => {
const progress = Math.min((now - start) / HOVER_DURATION, 1);
const opacity = from + (to - from) * progress;
const weight = fromWeight + (toWeight - fromWeight) * progress;
applyHoverStyle(features, opacity, weight);
if (progress < 1) {
hoverAnimationsRef.current.set(
groupKey,
requestAnimationFrame(animate)
);
} else {
hoverAnimationsRef.current.delete(groupKey);
}
};
hoverAnimationsRef.current.set(groupKey, requestAnimationFrame(animate));
};
let currentCityId = null;
let revertTimeout = null;
const collectCityFeatures = (cityId) => {
return cityFeatureIndex.get(cityId) ?? [];
};
const mouseoverListener = map.data.addListener(
"mouseover",
(event) => {
if (revertTimeout) {
clearTimeout(revertTimeout);
revertTimeout = null;
}
const regionId = event.feature.getProperty("regionId");
const region = stableData.find((r) => r.id === regionId);
if (region) {
setHoveredRegion(region);
if (event.domEvent) {
setInfoPosition({
x: event.domEvent.clientX,
y: event.domEvent.clientY
});
}
}
if (currentCityId !== regionId) {
if (currentCityId) {
const prevFeatures = collectCityFeatures(currentCityId);
animateHover(
currentCityId,
prevFeatures,
1,
TARGET_OPACITY,
1,
0.5
);
}
currentCityId = regionId;
const cityFeatures = collectCityFeatures(regionId);
animateHover(regionId, cityFeatures, TARGET_OPACITY, 1, 0.5, 1);
}
}
);
const mouseoutListener = map.data.addListener("mouseout", () => {
revertTimeout = setTimeout(() => {
setHoveredRegion(null);
setInfoPosition(null);
if (currentCityId) {
const prevFeatures = collectCityFeatures(currentCityId);
animateHover(currentCityId, prevFeatures, 1, TARGET_OPACITY, 1, 0.5);
currentCityId = null;
}
}, 50);
});
const mousemoveListener = map.data.addListener(
"mousemove",
(event) => {
if (event.domEvent) {
setInfoPosition({
x: event.domEvent.clientX,
y: event.domEvent.clientY
});
}
}
);
const computeCityBounds = (cityId) => {
const cityBounds = new google.maps.LatLngBounds();
for (const f of collectCityFeatures(cityId)) {
f.getGeometry()?.forEachLatLng((latLng) => {
cityBounds.extend(latLng);
});
}
return cityBounds;
};
const clickListener = map.data.addListener(
"click",
(event) => {
const regionId = event.feature.getProperty("regionId");
const region = stableData.find((r) => r.id === regionId);
if (region && region.isManagedRegion !== false) {
onRegionClickRef.current?.(region);
}
const cityBounds = computeCityBounds(regionId);
if (!cityBounds.isEmpty()) {
map.fitBounds(cityBounds, 20);
}
}
);
return () => {
if (fadeAnimationRef.current)
cancelAnimationFrame(fadeAnimationRef.current);
hoverAnimationsRef.current.forEach((id) => cancelAnimationFrame(id));
hoverAnimationsRef.current.clear();
if (revertTimeout) clearTimeout(revertTimeout);
if (nreBoundaryLayerRef.current) {
nreBoundaryLayerRef.current.setMap(null);
nreBoundaryLayerRef.current = null;
}
google.maps.event.removeListener(mouseoverListener);
google.maps.event.removeListener(mouseoutListener);
google.maps.event.removeListener(mousemoveListener);
google.maps.event.removeListener(clickListener);
};
}, [map, stableData, colorClasses, nreBoundaries]);
useEffect(() => {
if (!map || !stableData.length) return;
const isFiltering = activeClasses.size < colorClasses.length;
const visibleBounds = new google.maps.LatLngBounds();
let hasVisibleFeatures = false;
map.data.forEach((feature) => {
if (feature.getProperty("regionIsManaged") === false) {
map.data.overrideStyle(feature, { visible: true });
return;
}
const value = feature.getProperty("regionValue");
const colorClass = getColorClass(value ?? 0, colorClasses);
const classIndex = colorClasses.indexOf(colorClass);
if (activeClasses.has(classIndex)) {
map.data.overrideStyle(feature, { visible: true });
feature.getGeometry()?.forEachLatLng((latLng) => {
visibleBounds.extend(latLng);
});
hasVisibleFeatures = true;
} else {
map.data.overrideStyle(feature, { visible: false });
}
});
if (isFiltering && hasVisibleFeatures && !visibleBounds.isEmpty()) {
map.fitBounds(visibleBounds, 20);
}
}, [map, stableData, activeClasses, colorClasses]);
const toggleClass = (index) => {
setActiveClasses((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
};
if (loadError) {
return /* @__PURE__ */ jsx("div", { className: "p-5 bg-background border border-border-50 rounded-xl", children: /* @__PURE__ */ jsx(Text_default, { color: "text-error-700", children: "Erro ao carregar o mapa" }) });
}
return /* @__PURE__ */ jsxs(
"div",
{
className: cn(
"p-5 bg-background border border-border-50 rounded-xl flex flex-col gap-4",
className
),
children: [
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4", children: [
/* @__PURE__ */ jsxs("div", { className: "flex flex-row items-center gap-4", children: [
/* @__PURE__ */ jsx(
Text_default,
{
as: "h2",
size: "lg",
weight: "bold",
className: "flex-1 leading-[21px] tracking-[0.2px]",
children: title
}
),
headerAction
] }),
/* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-8", children: colorClasses.map((colorClass, index) => /* @__PURE__ */ jsx(
LegendItem,
{
color: colorClass.fillColor,
borderColor: colorClass.fillColor === colorClass.strokeColor ? void 0 : colorClass.strokeColor,
label: colorClass.label,
active: activeClasses.has(index),
onClick: () => toggleClass(index)
},
colorClass.label
)) })
] }),
/* @__PURE__ */ jsxs("div", { className: "bg-background-50 rounded-lg h-[415px] relative overflow-hidden", children: [
loading || !isLoaded ? /* @__PURE__ */ jsx(LoadingSkeleton, {}) : /* @__PURE__ */ jsx(
GoogleMap,
{
mapContainerStyle: containerStyle,
center: defaultCenter,
zoom: 7,
onLoad,
onUnmount,
options: mapOptions
}
),
hoveredRegion && infoPosition && /* @__PURE__ */ jsxs(
"div",
{
className: "fixed z-50 flex flex-col gap-2 bg-background-900 shadow-hard-shadow-2 rounded px-3 py-1 pointer-events-none",
style: {
left: Math.min(infoPosition.x + 10, window.innerWidth - 460),
top: Math.min(infoPosition.y + 10, window.innerHeight - 160)
},
children: [
/* @__PURE__ */ jsx(Text_default, { size: "md", weight: "semibold", color: "text-text-50", children: hoveredRegion.name }),
hoveredRegion.isManagedRegion !== false && /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx("div", { className: "h-px self-stretch bg-border-200" }),
hoveredRegion.accessBreakdown ? /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1", children: TOOLTIP_PROFILE_LINES.filter(
(line) => !activeProfile || line.key === activeProfile
).map((line) => {
const entry = hoveredRegion.accessBreakdown[line.key];
return /* @__PURE__ */ jsxs(Text_default, { size: "md", color: "text-text-50", children: [
line.label,
":",
" ",
entry.withAccess.toLocaleString("pt-BR"),
" com acesso,",
" ",
entry.withoutAccess.toLocaleString("pt-BR"),
" sem acessos"
] }, line.key);
}) }) : /* @__PURE__ */ jsxs(Text_default, { size: "md", color: "text-text-50", children: [
countLabel,
":",
" ",
hoveredRegion.accessCount.toLocaleString("pt-BR")
] })
] })
]
}
)
] }),
infoText && /* @__PURE__ */ jsx(
Alert_default,
{
action: "info",
variant: "solid",
description: infoText,
className: "w-full"
}
)
]
}
);
};
var ChoroplethMap_default = ChoroplethMap;
export {
ChoroplethMap_default
};
//# sourceMappingURL=chunk-7TM6LSSC.mjs.map