diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts index 2961a3a..053cf0f 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts @@ -318,30 +318,39 @@ export async function getCrimeByYearAndMonth( * @returns Array of distance calculations between units and incidents */ export async function calculateDistances( - unitId?: string, - districtId?: string + p_unit_id?: string, + p_district_id?: string ): Promise { - const supabase = createClient(); + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'Calculate Distances', + { recordResponse: true }, + async () => { + const supabase = createClient(); - try { - const { data, error } = await supabase.rpc( - 'calculate_unit_incident_distances', - { - unit_id: unitId || null, - district_id: districtId || null, + try { + const { data, error } = await supabase.rpc( + 'calculate_unit_incident_distances', + { + p_unit_id: p_unit_id || 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 []; - } + ); } diff --git a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx index 40f8d9e..27955ce 100644 --- a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx @@ -2,7 +2,7 @@ import { Button } from "@/app/_components/ui/button" 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 { 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 const crimeTooltips = [ { id: "incidents" as ITooltips, icon: , label: "All Incidents" }, - { id: "heatmap" as ITooltips, icon: , label: "Crime Heatmap" }, + { id: "heatmap" as ITooltips, icon: , label: "Density Heatmap" }, { id: "clusters" as ITooltips, icon: , label: "Clustered Incidents" }, { id: "units" as ITooltips, icon: , label: "Police Units" }, { id: "patrol" as ITooltips, icon: , label: "Patrol Areas" }, @@ -23,6 +23,15 @@ interface 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 (
@@ -36,7 +45,7 @@ export default function CrimeTooltips({ activeControl, onControlChange }: CrimeT ? "bg-emerald-500 text-black hover:bg-emerald-500/90" : "text-white hover:bg-emerald-500/90 hover:text-background" }`} - onClick={() => onControlChange?.(control.id)} + onClick={() => handleControlClick(control.id)} > {control.icon} {control.label} diff --git a/sigap-website/app/_components/map/layers/heatmap-layer.tsx b/sigap-website/app/_components/map/layers/heatmap-layer.tsx index 46b9e84..bbf89fb 100644 --- a/sigap-website/app/_components/map/layers/heatmap-layer.tsx +++ b/sigap-website/app/_components/map/layers/heatmap-layer.tsx @@ -8,14 +8,20 @@ interface HeatmapLayerProps { month: string; filterCategory: string | "all"; 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({ crimes, visible = true, useAllData = false, - filterCategory + filterCategory, + year, + month, + enableInteractions = true, + setFocusedDistrictId }: HeatmapLayerProps) { // Convert crime data to GeoJSON format for the heatmap const heatmapData = useMemo(() => { @@ -33,6 +39,17 @@ export default function HeatmapLayer({ 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; }) .map(incident => ({ @@ -42,6 +59,7 @@ export default function HeatmapLayer({ category: incident.crime_categories?.name || "Unknown", intensity: 1, // Base intensity value timestamp: incident.timestamp ? new Date(incident.timestamp).getTime() : null, + districtId: crime.district_id // Add district ID for potential interactions }, geometry: { type: "Point" as const, @@ -54,10 +72,14 @@ export default function HeatmapLayer({ type: "FeatureCollection" as const, features, }; - }, [crimes, filterCategory]); + }, [crimes, filterCategory, useAllData, year, month]); 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 ( ); -} +} \ No newline at end of file diff --git a/sigap-website/prisma/seeds/crime-incidents.ts b/sigap-website/prisma/seeds/crime-incidents.ts index 3e1cd53..58f8477 100644 --- a/sigap-website/prisma/seeds/crime-incidents.ts +++ b/sigap-website/prisma/seeds/crime-incidents.ts @@ -64,154 +64,93 @@ export class CrimeIncidentsSeeder { }); } - /** - * Generates well-distributed points within a district's area with geographical constraints - * @param centerLat - The center latitude of the district - * @param centerLng - The center longitude of the district - * @param landArea - Land area in square km - * @param numPoints - Number of points to generate - * @param districtId - ID of the district for special handling - * @param districtName - Name of the district for constraints - * @returns Array of {latitude, longitude, radius} points - */ - private generateDistributedPoints( - centerLat: number, - centerLng: number, - landArea: number, - numPoints: number, - districtId: string, - districtName: string - ): Array<{ latitude: number; longitude: number; radius: number }> { - const points = []; + /** + * Generates well-distributed points within a district's area with geographical constraints + * @param centerLat - The center latitude of the district + * @param centerLng - The center longitude of the district + * @param landArea - Land area in square km + * @param numPoints - Number of points to generate + * @param districtId - ID of the district for special handling + * @param districtName - Name of the district for constraints + * @returns Array of {latitude, longitude, radius} points + */ +private generateDistributedPoints( + centerLat: number, + centerLng: number, + landArea: number, + numPoints: number, + districtId: string, + districtName: string +): Array<{ latitude: number; longitude: number; radius: number }> { + const points = []; + const districtNameLower = districtName.toLowerCase(); - // Calculate a reasonable radius based on land area - const areaFactor = Math.sqrt(landArea) / 100; - const baseRadius = Math.max(0.01, Math.min(0.05, areaFactor * 0.03)); - - // Create a grid-based distribution for better coverage - const gridSize = Math.ceil(Math.sqrt(numPoints * 1.5)); - - // Define district bounds with geographical constraints - // Standard calculation for general districts - let estimatedDistrictRadius = Math.sqrt(landArea / Math.PI) / 111; - - // 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; + // Special centers for specific districts + if (districtNameLower === 'puger') { + centerLat = -8.28386264271547; + centerLng = 113.48075672799605; + // } else if (districtNameLower === 'tempurejo') { + // centerLat = -8.276417231420199; + // centerLng = 113.69726469589183; + } else if (districtNameLower === 'mumbulsari') { + centerLat = -8.25042152635794; + centerLng = 113.74209367242942; } + // 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 private async chunkedInsertIncidents(data: any[], chunkSize: number = 200) { 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 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( - geo.latitude, - geo.longitude, - geo.land_area || 100, // Default to 100 km² if not available + centerLat, + centerLng, + landArea, numLocations, district.id, district.name