refactor: enhance distance calculation and heatmap functionality; improve error handling and add interaction support

This commit is contained in:
vergiLgood1 2025-05-07 03:17:59 +07:00
parent 57fb1e4e46
commit 6bfc148821
4 changed files with 159 additions and 176 deletions

View File

@ -318,30 +318,39 @@ export async function getCrimeByYearAndMonth(
* @returns Array of distance calculations between units and incidents * @returns Array of distance calculations between units and incidents
*/ */
export async function calculateDistances( export async function calculateDistances(
unitId?: string, p_unit_id?: string,
districtId?: string p_district_id?: string
): Promise<IDistanceResult[]> { ): Promise<IDistanceResult[]> {
const supabase = createClient(); const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction(
'Calculate Distances',
{ recordResponse: true },
async () => {
const supabase = createClient();
try { try {
const { data, error } = await supabase.rpc( const { data, error } = await supabase.rpc(
'calculate_unit_incident_distances', 'calculate_unit_incident_distances',
{ {
unit_id: unitId || null, p_unit_id: p_unit_id || null,
district_id: districtId || null, p_district_id: p_district_id || null,
}
);
if (error) {
console.error('Error calculating distances:', error);
return [];
}
return data as IDistanceResult[] || [];
} catch (error) {
const crashReporterService = getInjection('ICrashReporterService');
crashReporterService.report(error);
console.error('Failed to calculate distances:', error);
return [];
} }
);
if (error) {
console.error('Error calculating distances:', error);
return [];
} }
);
return data || [];
} catch (error) {
console.error('Failed to calculate distances:', error);
return [];
}
} }

View File

@ -2,7 +2,7 @@
import { Button } from "@/app/_components/ui/button" import { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import { AlertTriangle, BarChart2, Building, Car, ChartScatter, Clock, Map, Shield, Users } from "lucide-react" import { AlertTriangle, BarChart2, Building, Car, ChartScatter, Clock, Thermometer, Shield, Users } from "lucide-react"
import { ITooltips } from "./tooltips" import { ITooltips } from "./tooltips"
import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react" import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react"
@ -10,7 +10,7 @@ import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react"
// Define the primary crime data controls // Define the primary crime data controls
const crimeTooltips = [ const crimeTooltips = [
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" }, { id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITooltips, icon: <Map size={20} />, label: "Crime Heatmap" }, { id: "heatmap" as ITooltips, icon: <Thermometer size={20} />, label: "Density Heatmap" },
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" }, { id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
{ id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" }, { id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" },
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" }, { id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
@ -23,6 +23,15 @@ interface CrimeTooltipsProps {
} }
export default function CrimeTooltips({ activeControl, onControlChange }: CrimeTooltipsProps) { export default function CrimeTooltips({ activeControl, onControlChange }: CrimeTooltipsProps) {
const handleControlClick = (controlId: ITooltips) => {
console.log("Clicked control:", controlId);
// Force the value to be set when clicking
if (onControlChange) {
onControlChange(controlId);
console.log("Control changed to:", controlId);
}
};
return ( return (
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1"> <div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider> <TooltipProvider>
@ -36,7 +45,7 @@ export default function CrimeTooltips({ activeControl, onControlChange }: CrimeT
? "bg-emerald-500 text-black hover:bg-emerald-500/90" ? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background" : "text-white hover:bg-emerald-500/90 hover:text-background"
}`} }`}
onClick={() => onControlChange?.(control.id)} onClick={() => handleControlClick(control.id)}
> >
{control.icon} {control.icon}
<span className="sr-only">{control.label}</span> <span className="sr-only">{control.label}</span>

View File

@ -8,14 +8,20 @@ interface HeatmapLayerProps {
month: string; month: string;
filterCategory: string | "all"; filterCategory: string | "all";
visible?: boolean; visible?: boolean;
useAllData?: boolean; // Add prop to indicate if we should use all data useAllData?: boolean;
enableInteractions?: boolean; // Add new prop
setFocusedDistrictId?: (id: string | null, isMarkerClick?: boolean) => void; // Add new prop
} }
export default function HeatmapLayer({ export default function HeatmapLayer({
crimes, crimes,
visible = true, visible = true,
useAllData = false, useAllData = false,
filterCategory filterCategory,
year,
month,
enableInteractions = true,
setFocusedDistrictId
}: HeatmapLayerProps) { }: HeatmapLayerProps) {
// Convert crime data to GeoJSON format for the heatmap // Convert crime data to GeoJSON format for the heatmap
const heatmapData = useMemo(() => { const heatmapData = useMemo(() => {
@ -33,6 +39,17 @@ export default function HeatmapLayer({
return false; return false;
} }
// Filter by year and month if not using all data
if (!useAllData && year && month) {
const incidentDate = new Date(incident.timestamp);
const incidentYear = incidentDate.getFullYear().toString();
const incidentMonth = (incidentDate.getMonth() + 1).toString();
if (incidentYear !== year || incidentMonth !== month) {
return false;
}
}
return true; return true;
}) })
.map(incident => ({ .map(incident => ({
@ -42,6 +59,7 @@ export default function HeatmapLayer({
category: incident.crime_categories?.name || "Unknown", category: incident.crime_categories?.name || "Unknown",
intensity: 1, // Base intensity value intensity: 1, // Base intensity value
timestamp: incident.timestamp ? new Date(incident.timestamp).getTime() : null, timestamp: incident.timestamp ? new Date(incident.timestamp).getTime() : null,
districtId: crime.district_id // Add district ID for potential interactions
}, },
geometry: { geometry: {
type: "Point" as const, type: "Point" as const,
@ -54,10 +72,14 @@ export default function HeatmapLayer({
type: "FeatureCollection" as const, type: "FeatureCollection" as const,
features, features,
}; };
}, [crimes, filterCategory]); }, [crimes, filterCategory, useAllData, year, month]);
if (!visible) return null; if (!visible) return null;
// The heatmap layer doesn't generally support direct interactions like clicks,
// but we're including the props to maintain consistency with other layers
// and to support future interaction patterns if needed
return ( return (
<Source id="crime-heatmap-data" type="geojson" data={heatmapData}> <Source id="crime-heatmap-data" type="geojson" data={heatmapData}>
<Layer <Layer
@ -106,4 +128,4 @@ export default function HeatmapLayer({
/> />
</Source> </Source>
); );
} }

View File

@ -64,154 +64,93 @@ export class CrimeIncidentsSeeder {
}); });
} }
/** /**
* Generates well-distributed points within a district's area with geographical constraints * Generates well-distributed points within a district's area with geographical constraints
* @param centerLat - The center latitude of the district * @param centerLat - The center latitude of the district
* @param centerLng - The center longitude of the district * @param centerLng - The center longitude of the district
* @param landArea - Land area in square km * @param landArea - Land area in square km
* @param numPoints - Number of points to generate * @param numPoints - Number of points to generate
* @param districtId - ID of the district for special handling * @param districtId - ID of the district for special handling
* @param districtName - Name of the district for constraints * @param districtName - Name of the district for constraints
* @returns Array of {latitude, longitude, radius} points * @returns Array of {latitude, longitude, radius} points
*/ */
private generateDistributedPoints( private generateDistributedPoints(
centerLat: number, centerLat: number,
centerLng: number, centerLng: number,
landArea: number, landArea: number,
numPoints: number, numPoints: number,
districtId: string, districtId: string,
districtName: string districtName: string
): Array<{ latitude: number; longitude: number; radius: number }> { ): Array<{ latitude: number; longitude: number; radius: number }> {
const points = []; const points = [];
const districtNameLower = districtName.toLowerCase();
// Calculate a reasonable radius based on land area // Special centers for specific districts
const areaFactor = Math.sqrt(landArea) / 100; if (districtNameLower === 'puger') {
const baseRadius = Math.max(0.01, Math.min(0.05, areaFactor * 0.03)); centerLat = -8.28386264271547;
centerLng = 113.48075672799605;
// Create a grid-based distribution for better coverage // } else if (districtNameLower === 'tempurejo') {
const gridSize = Math.ceil(Math.sqrt(numPoints * 1.5)); // centerLat = -8.276417231420199;
// centerLng = 113.69726469589183;
// Define district bounds with geographical constraints } else if (districtNameLower === 'mumbulsari') {
// Standard calculation for general districts centerLat = -8.25042152635794;
let estimatedDistrictRadius = Math.sqrt(landArea / Math.PI) / 111; centerLng = 113.74209367242942;
// District-specific adjustments to avoid generating points in the ocean
const southernCoastalDistricts = [
'puger',
'tempurejo',
'ambulu',
'gumukmas',
'kencong',
'wuluhan',
'kencong',
];
const isCoastalDistrict = southernCoastalDistricts.some((district) =>
districtName.toLowerCase().includes(district)
);
// Default bounds
let bounds = {
minLat: centerLat - estimatedDistrictRadius,
maxLat: centerLat + estimatedDistrictRadius,
minLng: centerLng - estimatedDistrictRadius,
maxLng: centerLng + estimatedDistrictRadius,
};
// Apply special constraints for coastal districts
if (isCoastalDistrict) {
// Shift points northward for southern coastal districts to avoid ocean
if (
districtName.toLowerCase().includes('puger') ||
districtName.toLowerCase().includes('tempurejo')
) {
// For Puger and Tempurejo, shift more aggressively northward
bounds = {
minLat: centerLat, // Don't go south of the center
maxLat: centerLat + estimatedDistrictRadius * 1.5, // Extend more to the north
minLng: centerLng - estimatedDistrictRadius * 0.8,
maxLng: centerLng + estimatedDistrictRadius * 0.8,
};
} else {
// For other coastal districts, shift moderately northward
bounds = {
minLat: centerLat - estimatedDistrictRadius * 0.5, // Less southward
maxLat: centerLat + estimatedDistrictRadius * 1.2, // More northward
minLng: centerLng - estimatedDistrictRadius,
maxLng: centerLng + estimatedDistrictRadius,
};
}
}
const latStep = (bounds.maxLat - bounds.minLat) / gridSize;
const lngStep = (bounds.maxLng - bounds.minLng) / gridSize;
// Generate points in each grid cell with some randomness
let totalPoints = 0;
for (let i = 0; i < gridSize && totalPoints < numPoints; i++) {
for (let j = 0; j < gridSize && totalPoints < numPoints; j++) {
// Base position within the grid cell with randomness
const cellLat =
bounds.minLat + (i + 0.2 + Math.random() * 0.6) * latStep;
const cellLng =
bounds.minLng + (j + 0.2 + Math.random() * 0.6) * lngStep;
// Distance from center (for radius reference)
const latDiff = cellLat - centerLat;
const lngDiff = cellLng - centerLng;
const distance = Math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
// Add some randomness to avoid perfect grid pattern
const jitter = baseRadius * 0.2;
const latitude = cellLat + (Math.random() * 2 - 1) * jitter;
const longitude = cellLng + (Math.random() * 2 - 1) * jitter;
// Ensure the point is within district boundaries
// Simple check to ensure points don't stray too far from center
if (distance <= estimatedDistrictRadius * 1.2) {
points.push({
latitude,
longitude,
radius: distance * 111000, // Convert to meters (approx)
});
totalPoints++;
}
}
}
// If we still need more points, add some with tighter constraints
while (points.length < numPoints) {
// For coastal districts, use more controlled distribution
let latitude, longitude;
if (isCoastalDistrict) {
// Generate points with northward bias for coastal districts
const northBias = Math.random() * 0.7 + 0.3; // 0.3 to 1.0, favoring north
latitude = centerLat + northBias * estimatedDistrictRadius * 0.8;
longitude =
centerLng + (Math.random() * 2 - 1) * estimatedDistrictRadius * 0.8;
} else {
// Standard distribution for non-coastal districts
latitude =
centerLat + (Math.random() * 2 - 1) * estimatedDistrictRadius * 0.8;
longitude =
centerLng + (Math.random() * 2 - 1) * estimatedDistrictRadius * 0.8;
}
const latDiff = latitude - centerLat;
const lngDiff = longitude - centerLng;
const distance = Math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
points.push({
latitude,
longitude,
radius: distance * 111000, // Convert to meters (approx)
});
}
return points;
} }
// Calculate radius proportional to sqrt(landArea) but with better constraints
let scalingFactor = 0.3;
// Special radius handling for specific districts
if (districtNameLower === 'mumbulsari') {
scalingFactor = 0.1; // Tighter scaling for Mumbulsari
} else if (landArea > 300) {
scalingFactor = 0.25;
} else if (landArea < 50) {
scalingFactor = 0.35;
}
const radiusKm = Math.sqrt(landArea) * scalingFactor;
const radiusDeg = radiusKm / 111;
// Generate points in a circle around the center
for (let i = 0; i < numPoints; i++) {
const angle = Math.random() * 2 * Math.PI;
// Adjust distance factor for Mumbulsari and Tempurejo
let distanceFactor = Math.pow(Math.random(), 1.5);
if (districtNameLower === 'mumbulsari') {
distanceFactor = Math.pow(Math.random(), 2); // Concentrate points closer to the center
}
const distance = distanceFactor * radiusDeg;
let latitude = centerLat + distance * Math.cos(angle);
let longitude = centerLng + distance * Math.sin(angle) / Math.cos(centerLat * Math.PI / 180);
// Bias points for Tempurejo towards the south
// if (districtNameLower === 'tempurejo') {
// latitude -= Math.abs(distance * 0.2); // Shift points slightly southward
// }
let pointRadius = distance * 111000;
if (districtNameLower === 'mumbulsari') {
pointRadius = Math.min(pointRadius, 5000);
} else {
const maxRadiusMeters = Math.min(10000, radiusKm * 500);
pointRadius = Math.min(pointRadius, maxRadiusMeters);
}
points.push({
latitude,
longitude,
radius: pointRadius,
});
}
return points;
}
// Helper for chunked insertion // Helper for chunked insertion
private async chunkedInsertIncidents(data: any[], chunkSize: number = 200) { private async chunkedInsertIncidents(data: any[], chunkSize: number = 200) {
for (let i = 0; i < data.length; i += chunkSize) { for (let i = 0; i < data.length; i += chunkSize) {
@ -340,11 +279,15 @@ export class CrimeIncidentsSeeder {
// Use the actual number of crimes instead of a random count // Use the actual number of crimes instead of a random count
const numLocations = crime.number_of_crime; const numLocations = crime.number_of_crime;
// Update the call to the function in createIncidentsForCrime method: // Gunakan hanya geographic sebagai center
const centerLat = geo.latitude;
const centerLng = geo.longitude;
const landArea = geo.land_area ?? 100;
const locationPool = this.generateDistributedPoints( const locationPool = this.generateDistributedPoints(
geo.latitude, centerLat,
geo.longitude, centerLng,
geo.land_area || 100, // Default to 100 km² if not available landArea,
numLocations, numLocations,
district.id, district.id,
district.name district.name