add filter source_type
This commit is contained in:
parent
77c865958a
commit
2a8f249d0c
|
@ -4,6 +4,8 @@ import {
|
||||||
getCrimeByYearAndMonth,
|
getCrimeByYearAndMonth,
|
||||||
getCrimeCategories,
|
getCrimeCategories,
|
||||||
getCrimes,
|
getCrimes,
|
||||||
|
getCrimesTypes,
|
||||||
|
getRecentIncidents,
|
||||||
} from '../action';
|
} from '../action';
|
||||||
|
|
||||||
export const useGetAvailableYears = () => {
|
export const useGetAvailableYears = () => {
|
||||||
|
@ -36,3 +38,17 @@ export const useGetCrimeCategories = () => {
|
||||||
queryFn: () => getCrimeCategories(),
|
queryFn: () => getCrimeCategories(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useGetCrimeTypes = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['crime-types'],
|
||||||
|
queryFn: () => getCrimesTypes(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetRecentIncidents = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['recent-incidents'],
|
||||||
|
queryFn: () => getRecentIncidents(),
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,24 +1,26 @@
|
||||||
'use server';
|
"use server";
|
||||||
|
|
||||||
import { createClient } from '@/app/_utils/supabase/client';
|
import { getSeverity } from "@/app/_utils/crime";
|
||||||
|
import { createClient } from "@/app/_utils/supabase/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ICrimes,
|
ICrimes,
|
||||||
ICrimesByYearAndMonth,
|
ICrimesByYearAndMonth,
|
||||||
IDistanceResult,
|
IDistanceResult,
|
||||||
} from '@/app/_utils/types/crimes';
|
IIncidentLogs,
|
||||||
import { getInjection } from '@/di/container';
|
} from "@/app/_utils/types/crimes";
|
||||||
import db from '@/prisma/db';
|
import { getInjection } from "@/di/container";
|
||||||
|
import db from "@/prisma/db";
|
||||||
import {
|
import {
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
UnauthenticatedError,
|
UnauthenticatedError,
|
||||||
} from '@/src/entities/errors/auth';
|
} from "@/src/entities/errors/auth";
|
||||||
import { InputParseError } from '@/src/entities/errors/common';
|
import { InputParseError } from "@/src/entities/errors/common";
|
||||||
|
|
||||||
export async function getAvailableYears() {
|
export async function getAvailableYears() {
|
||||||
const instrumentationService = getInjection('IInstrumentationService');
|
const instrumentationService = getInjection("IInstrumentationService");
|
||||||
return await instrumentationService.instrumentServerAction(
|
return await instrumentationService.instrumentServerAction(
|
||||||
'Available Years',
|
"Available Years",
|
||||||
{ recordResponse: true },
|
{ recordResponse: true },
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -26,50 +28,38 @@ export async function getAvailableYears() {
|
||||||
select: {
|
select: {
|
||||||
year: true,
|
year: true,
|
||||||
},
|
},
|
||||||
distinct: ['year'],
|
distinct: ["year"],
|
||||||
orderBy: {
|
orderBy: {
|
||||||
year: 'asc',
|
year: "asc",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return years.map((year) => year.year);
|
return years.map((year) => year.year);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InputParseError) {
|
if (err instanceof InputParseError) {
|
||||||
// return {
|
|
||||||
// error: err.message,
|
|
||||||
// };
|
|
||||||
|
|
||||||
throw new InputParseError(err.message);
|
throw new InputParseError(err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof AuthenticationError) {
|
if (err instanceof AuthenticationError) {
|
||||||
// return {
|
|
||||||
// error: 'User not found.',
|
|
||||||
// };
|
|
||||||
|
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
'There was an error with the credentials. Please try again or contact support.'
|
"There was an error with the credentials. Please try again or contact support.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const crashReporterService = getInjection('ICrashReporterService');
|
const crashReporterService = getInjection("ICrashReporterService");
|
||||||
crashReporterService.report(err);
|
crashReporterService.report(err);
|
||||||
// return {
|
|
||||||
// error:
|
|
||||||
// 'An error happened. The developers have been notified. Please try again later.',
|
|
||||||
// };
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'An error happened. The developers have been notified. Please try again later.'
|
"An error happened. The developers have been notified. Please try again later.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCrimeCategories() {
|
export async function getCrimeCategories() {
|
||||||
const instrumentationService = getInjection('IInstrumentationService');
|
const instrumentationService = getInjection("IInstrumentationService");
|
||||||
return await instrumentationService.instrumentServerAction(
|
return await instrumentationService.instrumentServerAction(
|
||||||
'Crime Categories',
|
"Crime Categories",
|
||||||
{ recordResponse: true },
|
{ recordResponse: true },
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -84,41 +74,29 @@ export async function getCrimeCategories() {
|
||||||
return categories;
|
return categories;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InputParseError) {
|
if (err instanceof InputParseError) {
|
||||||
// return {
|
|
||||||
// error: err.message,
|
|
||||||
// };
|
|
||||||
|
|
||||||
throw new InputParseError(err.message);
|
throw new InputParseError(err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof AuthenticationError) {
|
if (err instanceof AuthenticationError) {
|
||||||
// return {
|
|
||||||
// error: 'User not found.',
|
|
||||||
// };
|
|
||||||
|
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
'There was an error with the credentials. Please try again or contact support.'
|
"There was an error with the credentials. Please try again or contact support.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const crashReporterService = getInjection('ICrashReporterService');
|
const crashReporterService = getInjection("ICrashReporterService");
|
||||||
crashReporterService.report(err);
|
crashReporterService.report(err);
|
||||||
// return {
|
|
||||||
// error:
|
|
||||||
// 'An error happened. The developers have been notified. Please try again later.',
|
|
||||||
// };
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'An error happened. The developers have been notified. Please try again later.'
|
"An error happened. The developers have been notified. Please try again later.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCrimes(): Promise<ICrimes[]> {
|
export async function getCrimes(): Promise<ICrimes[]> {
|
||||||
const instrumentationService = getInjection('IInstrumentationService');
|
const instrumentationService = getInjection("IInstrumentationService");
|
||||||
return await instrumentationService.instrumentServerAction(
|
return await instrumentationService.instrumentServerAction(
|
||||||
'District Crime Data',
|
"District Crime Data",
|
||||||
{ recordResponse: true },
|
{ recordResponse: true },
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -173,54 +151,121 @@ export async function getCrimes(): Promise<ICrimes[]> {
|
||||||
return crimes;
|
return crimes;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InputParseError) {
|
if (err instanceof InputParseError) {
|
||||||
// return {
|
|
||||||
// error: err.message,
|
|
||||||
// };
|
|
||||||
|
|
||||||
throw new InputParseError(err.message);
|
throw new InputParseError(err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err instanceof AuthenticationError) {
|
if (err instanceof AuthenticationError) {
|
||||||
// return {
|
|
||||||
// error: 'User not found.',
|
|
||||||
// };
|
|
||||||
|
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
'There was an error with the credentials. Please try again or contact support.'
|
"There was an error with the credentials. Please try again or contact support.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const crashReporterService = getInjection('ICrashReporterService');
|
const crashReporterService = getInjection("ICrashReporterService");
|
||||||
crashReporterService.report(err);
|
crashReporterService.report(err);
|
||||||
// return {
|
|
||||||
// error:
|
|
||||||
// 'An error happened. The developers have been notified. Please try again later.',
|
|
||||||
// };
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'An error happened. The developers have been notified. Please try again later.'
|
"An error happened. The developers have been notified. Please try again later.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentIncidents(): Promise<IIncidentLogs[]> {
|
||||||
|
const instrumentationService = getInjection("IInstrumentationService");
|
||||||
|
return await instrumentationService.instrumentServerAction(
|
||||||
|
"Recent Incidents",
|
||||||
|
{ recordResponse: true },
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const incidents = await db.incident_logs.findMany({
|
||||||
|
where: {
|
||||||
|
time: {
|
||||||
|
gte: yesterday,
|
||||||
|
lte: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
time: "desc",
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
crime_categories: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locations: {
|
||||||
|
select: {
|
||||||
|
districts: {
|
||||||
|
select: {
|
||||||
|
name:true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
address: true,
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map DB result to IIncidentLogs interface
|
||||||
|
return incidents.map((incident) => ({
|
||||||
|
id: incident.id,
|
||||||
|
user_id: incident.user_id,
|
||||||
|
latitude: incident.locations?.latitude ?? null,
|
||||||
|
longitude: incident.locations?.longitude ?? null,
|
||||||
|
district: incident.locations.districts.name ?? "",
|
||||||
|
address: incident.locations?.address ?? "",
|
||||||
|
category: incident.crime_categories?.name ?? "",
|
||||||
|
source: incident.source ?? "",
|
||||||
|
description: incident.description ?? "",
|
||||||
|
verified: incident.verified ?? false,
|
||||||
|
severity: getSeverity(incident.crime_categories.name),
|
||||||
|
timestamp: incident.time,
|
||||||
|
created_at: incident.created_at ?? incident.time ?? new Date(),
|
||||||
|
updated_at: incident.updated_at ?? incident.time ?? new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InputParseError) {
|
||||||
|
throw new InputParseError(err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
throw new AuthenticationError(
|
||||||
|
"There was an error with the credentials. Please try again or contact support.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const crashReporterService = getInjection("ICrashReporterService");
|
||||||
|
crashReporterService.report(err);
|
||||||
|
throw new Error(
|
||||||
|
"An error happened. The developers have been notified. Please try again later.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCrimeByYearAndMonth(
|
export async function getCrimeByYearAndMonth(
|
||||||
year: number,
|
year: number,
|
||||||
month: number | 'all'
|
month: number | "all",
|
||||||
): Promise<ICrimesByYearAndMonth[]> {
|
): Promise<ICrimesByYearAndMonth[]> {
|
||||||
const instrumentationService = getInjection('IInstrumentationService');
|
const instrumentationService = getInjection("IInstrumentationService");
|
||||||
return await instrumentationService.instrumentServerAction(
|
return await instrumentationService.instrumentServerAction(
|
||||||
'District Crime Data',
|
"District Crime Data",
|
||||||
{ recordResponse: true },
|
{ recordResponse: true },
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
// Build where clause conditionally based on provided parameters
|
|
||||||
const whereClause: any = {
|
const whereClause: any = {
|
||||||
year: year, // Always filter by year now since "all" is removed
|
year: year,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only add month to filter if it's not "all"
|
if (month !== "all") {
|
||||||
if (month !== 'all') {
|
|
||||||
whereClause.month = month;
|
whereClause.month = month;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,7 +276,7 @@ export async function getCrimeByYearAndMonth(
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
geographics: {
|
geographics: {
|
||||||
where: { year }, // Match geographics to selected year
|
where: { year },
|
||||||
select: {
|
select: {
|
||||||
address: true,
|
address: true,
|
||||||
land_area: true,
|
land_area: true,
|
||||||
|
@ -241,7 +286,7 @@ export async function getCrimeByYearAndMonth(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
demographics: {
|
demographics: {
|
||||||
where: { year }, // Match demographics to selected year
|
where: { year },
|
||||||
select: {
|
select: {
|
||||||
number_of_unemployed: true,
|
number_of_unemployed: true,
|
||||||
population: true,
|
population: true,
|
||||||
|
@ -275,15 +320,12 @@ export async function getCrimeByYearAndMonth(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process the data to transform geographics and demographics from array to single object
|
|
||||||
const processedCrimes = crimes.map((crime) => {
|
const processedCrimes = crimes.map((crime) => {
|
||||||
return {
|
return {
|
||||||
...crime,
|
...crime,
|
||||||
districts: {
|
districts: {
|
||||||
...crime.districts,
|
...crime.districts,
|
||||||
// Convert geographics array to single object matching the year
|
|
||||||
geographics: crime.districts.geographics[0] || null,
|
geographics: crime.districts.geographics[0] || null,
|
||||||
// Convert demographics array to single object matching the year
|
|
||||||
demographics: crime.districts.demographics[0] || null,
|
demographics: crime.districts.demographics[0] || null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -297,17 +339,17 @@ export async function getCrimeByYearAndMonth(
|
||||||
|
|
||||||
if (err instanceof AuthenticationError) {
|
if (err instanceof AuthenticationError) {
|
||||||
throw new AuthenticationError(
|
throw new AuthenticationError(
|
||||||
'There was an error with the credentials. Please try again or contact support.'
|
"There was an error with the credentials. Please try again or contact support.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const crashReporterService = getInjection('ICrashReporterService');
|
const crashReporterService = getInjection("ICrashReporterService");
|
||||||
crashReporterService.report(err);
|
crashReporterService.report(err);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'An error happened. The developers have been notified. Please try again later.'
|
"An error happened. The developers have been notified. Please try again later.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -319,37 +361,77 @@ export async function getCrimeByYearAndMonth(
|
||||||
*/
|
*/
|
||||||
export async function calculateDistances(
|
export async function calculateDistances(
|
||||||
p_unit_id?: string,
|
p_unit_id?: string,
|
||||||
p_district_id?: string
|
p_district_id?: string,
|
||||||
): Promise<IDistanceResult[]> {
|
): Promise<IDistanceResult[]> {
|
||||||
const instrumentationService = getInjection('IInstrumentationService');
|
const instrumentationService = getInjection("IInstrumentationService");
|
||||||
return await instrumentationService.instrumentServerAction(
|
return await instrumentationService.instrumentServerAction(
|
||||||
'Calculate Distances',
|
"Calculate Distances",
|
||||||
{ recordResponse: true },
|
{ recordResponse: true },
|
||||||
async () => {
|
async () => {
|
||||||
const supabase = createClient();
|
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",
|
||||||
{
|
{
|
||||||
p_unit_id: p_unit_id || null,
|
p_unit_id: p_unit_id || null,
|
||||||
p_district_id: p_district_id || null,
|
p_district_id: p_district_id || null,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error calculating distances:', error);
|
console.error("Error calculating distances:", error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as IDistanceResult[] || [];
|
return data as IDistanceResult[] || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const crashReporterService = getInjection('ICrashReporterService');
|
const crashReporterService = getInjection("ICrashReporterService");
|
||||||
crashReporterService.report(error);
|
crashReporterService.report(error);
|
||||||
console.error('Failed to calculate distances:', error);
|
console.error("Failed to calculate distances:", error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCrimesTypes(): Promise<string[]> {
|
||||||
|
const instrumentationService = getInjection("IInstrumentationService");
|
||||||
|
return await instrumentationService.instrumentServerAction(
|
||||||
|
"Crime Types",
|
||||||
|
{ recordResponse: true },
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const types = await db.crimes.findMany({
|
||||||
|
distinct: ["source_type"],
|
||||||
|
select: {
|
||||||
|
source_type: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a clean array of strings with no nulls
|
||||||
|
return types
|
||||||
|
.map((t) => t.source_type)
|
||||||
|
.filter((t): t is string => t !== null && t !== undefined);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InputParseError) {
|
||||||
|
throw new InputParseError(err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
throw new AuthenticationError(
|
||||||
|
"There was an error with the credentials. Please try again or contact support.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const crashReporterService = getInjection("ICrashReporterService");
|
||||||
|
crashReporterService.report(err);
|
||||||
|
throw new Error(
|
||||||
|
"An error happened. The developers have been notified. Please try again later.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,6 +27,7 @@ interface CrimeSidebarProps {
|
||||||
selectedMonth?: number | "all"
|
selectedMonth?: number | "all"
|
||||||
crimes: ICrimes[]
|
crimes: ICrimes[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
sourceType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrimeSidebar({
|
export default function CrimeSidebar({
|
||||||
|
@ -37,6 +38,7 @@ export default function CrimeSidebar({
|
||||||
selectedMonth,
|
selectedMonth,
|
||||||
crimes = [],
|
crimes = [],
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
sourceType = "cbt",
|
||||||
}: CrimeSidebarProps) {
|
}: CrimeSidebarProps) {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
||||||
const [activeTab, setActiveTab] = useState("incidents")
|
const [activeTab, setActiveTab] = useState("incidents")
|
||||||
|
@ -60,13 +62,19 @@ export default function CrimeSidebar({
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Set default tab based on source type
|
||||||
|
useEffect(() => {
|
||||||
|
if (sourceType === "cbu") {
|
||||||
|
setActiveTab("incidents")
|
||||||
|
}
|
||||||
|
}, [sourceType])
|
||||||
|
|
||||||
// Format date with selected year and month if provided
|
// Format date with selected year and month if provided
|
||||||
const getDisplayDate = () => {
|
const getDisplayDate = () => {
|
||||||
// If we have a specific month selected, use that for display
|
|
||||||
if (selectedMonth && selectedMonth !== 'all') {
|
if (selectedMonth && selectedMonth !== 'all') {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
date.setFullYear(selectedYear)
|
date.setFullYear(selectedYear)
|
||||||
date.setMonth(Number(selectedMonth) - 1) // Month is 0-indexed in JS Date
|
date.setMonth(Number(selectedMonth) - 1)
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
@ -74,7 +82,6 @@ export default function CrimeSidebar({
|
||||||
}).format(date)
|
}).format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise show today's date
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
|
@ -91,7 +98,6 @@ export default function CrimeSidebar({
|
||||||
hour12: true
|
hour12: true
|
||||||
}).format(currentTime)
|
}).format(currentTime)
|
||||||
|
|
||||||
// Generate a time period display for the current view
|
|
||||||
const getTimePeriodDisplay = () => {
|
const getTimePeriodDisplay = () => {
|
||||||
if (selectedMonth && selectedMonth !== 'all') {
|
if (selectedMonth && selectedMonth !== 'all') {
|
||||||
return `${getMonthName(Number(selectedMonth))} ${selectedYear}`
|
return `${getMonthName(Number(selectedMonth))} ${selectedYear}`
|
||||||
|
@ -99,31 +105,8 @@ export default function CrimeSidebar({
|
||||||
return `${selectedYear} - All months`
|
return `${selectedYear} - All months`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to fly to incident location when clicked
|
|
||||||
const handleIncidentClick = (incident: any) => {
|
const handleIncidentClick = (incident: any) => {
|
||||||
if (!map || !incident.longitude || !incident.latitude) return
|
if (!map || !incident.longitude || !incident.latitude) return
|
||||||
|
|
||||||
// Fly to the incident location
|
|
||||||
// map.flyTo({
|
|
||||||
// center: [incident.longitude, incident.latitude],
|
|
||||||
// zoom: 15,
|
|
||||||
// pitch: 0,
|
|
||||||
// bearing: 0,
|
|
||||||
// duration: 1500,
|
|
||||||
// easing: (t) => t * (2 - t), // easeOutQuad
|
|
||||||
// })
|
|
||||||
|
|
||||||
// // Create and dispatch a custom event for the incident click
|
|
||||||
// const customEvent = new CustomEvent("incident_click", {
|
|
||||||
// detail: incident,
|
|
||||||
// bubbles: true
|
|
||||||
// })
|
|
||||||
|
|
||||||
// if (map.getMap().getCanvas()) {
|
|
||||||
// map.getMap().getCanvas().dispatchEvent(customEvent)
|
|
||||||
// } else {
|
|
||||||
// document.dispatchEvent(customEvent)
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -135,7 +118,6 @@ export default function CrimeSidebar({
|
||||||
<div className="relative h-full flex items-stretch">
|
<div className="relative h-full flex items-stretch">
|
||||||
<div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]">
|
<div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]">
|
||||||
<div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden">
|
<div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden">
|
||||||
{/* Header with improved styling */}
|
|
||||||
<CardHeader className="p-0 pb-4 shrink-0 relative">
|
<CardHeader className="p-0 pb-4 shrink-0 relative">
|
||||||
<div className="absolute top-0 right-0">
|
<div className="absolute top-0 right-0">
|
||||||
<Button
|
<Button
|
||||||
|
@ -155,6 +137,11 @@ export default function CrimeSidebar({
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-xl font-semibold">
|
<CardTitle className="text-xl font-semibold">
|
||||||
Crime Analysis
|
Crime Analysis
|
||||||
|
{sourceType && (
|
||||||
|
<span className="ml-2 text-xs font-normal px-2 py-1 bg-sidebar-accent rounded-full">
|
||||||
|
{sourceType.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<CardDescription className="text-sm text-sidebar-foreground/70">
|
<CardDescription className="text-sm text-sidebar-foreground/70">
|
||||||
|
@ -165,7 +152,6 @@ export default function CrimeSidebar({
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{/* Improved tabs with pill style */}
|
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="incidents"
|
defaultValue="incidents"
|
||||||
className="w-full flex-1 flex flex-col overflow-hidden"
|
className="w-full flex-1 flex flex-col overflow-hidden"
|
||||||
|
@ -173,12 +159,14 @@ export default function CrimeSidebar({
|
||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
>
|
>
|
||||||
<TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full">
|
<TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full">
|
||||||
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="incidents"
|
value="incidents"
|
||||||
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
||||||
>
|
>
|
||||||
Dashboard
|
Dashboard
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="statistics"
|
value="statistics"
|
||||||
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
||||||
|
@ -211,6 +199,7 @@ export default function CrimeSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
|
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
|
||||||
<SidebarIncidentsTab
|
<SidebarIncidentsTab
|
||||||
crimeStats={crimeStats}
|
crimeStats={crimeStats}
|
||||||
|
@ -226,6 +215,8 @@ export default function CrimeSidebar({
|
||||||
handleIncidentClick={handleIncidentClick}
|
handleIncidentClick={handleIncidentClick}
|
||||||
activeIncidentTab={activeIncidentTab}
|
activeIncidentTab={activeIncidentTab}
|
||||||
setActiveIncidentTab={setActiveIncidentTab}
|
setActiveIncidentTab={setActiveIncidentTab}
|
||||||
|
sourceType={sourceType}
|
||||||
|
// setActiveTab={setActiveTab} // Pass setActiveTab function
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
@ -234,11 +225,13 @@ export default function CrimeSidebar({
|
||||||
crimeStats={crimeStats}
|
crimeStats={crimeStats}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
|
sourceType={sourceType}
|
||||||
|
crimes={crimes}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="info" className="m-0 p-0 space-y-4">
|
<TabsContent value="info" className="m-0 p-0 space-y-4">
|
||||||
<SidebarInfoTab />
|
<SidebarInfoTab sourceType={sourceType} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { AlertTriangle, AlertCircle, Clock, Shield, MapPin, ChevronLeft, ChevronRight, FileText, Calendar } from 'lucide-react'
|
import { AlertTriangle, AlertCircle, Clock, Shield, MapPin, ChevronLeft, ChevronRight, FileText, Calendar, ArrowRight, RefreshCw } from 'lucide-react'
|
||||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
import { Card, CardContent } from "@/app/_components/ui/card"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
|
||||||
import { Badge } from "@/app/_components/ui/badge"
|
import { Badge } from "@/app/_components/ui/badge"
|
||||||
|
@ -59,8 +59,9 @@ export function SidebarIncidentsTab({
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
handleIncidentClick,
|
handleIncidentClick,
|
||||||
activeIncidentTab,
|
activeIncidentTab,
|
||||||
setActiveIncidentTab
|
setActiveIncidentTab,
|
||||||
}: SidebarIncidentsTabProps) {
|
sourceType = "cbt"
|
||||||
|
}: SidebarIncidentsTabProps & { sourceType?: string }) {
|
||||||
const topCategories = crimeStats.categoryCounts ?
|
const topCategories = crimeStats.categoryCounts ?
|
||||||
Object.entries(crimeStats.categoryCounts)
|
Object.entries(crimeStats.categoryCounts)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
@ -70,6 +71,58 @@ export function SidebarIncidentsTab({
|
||||||
return { type, count, percentage }
|
return { type, count, percentage }
|
||||||
}) : []
|
}) : []
|
||||||
|
|
||||||
|
// If source type is CBU, display warning instead of regular content
|
||||||
|
if (sourceType === "cbu") {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-r from-emerald-900/20 to-emerald-800/10 border border-emerald-500/30">
|
||||||
|
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||||
|
<div className="bg-emerald-500/20 rounded-full p-3 mb-3">
|
||||||
|
<AlertTriangle className="h-8 w-8 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Limited Data View</h3>
|
||||||
|
|
||||||
|
<p className="text-white/80 mb-4">
|
||||||
|
The CBU data source only provides aggregated statistics without detailed incident information.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-black/20 rounded-lg p-3 w-full mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-white/60 text-sm">Current Data Source:</span>
|
||||||
|
<span className="font-medium text-emerald-400 text-sm">CBU</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60 text-sm">Recommended:</span>
|
||||||
|
<span className="font-medium text-blue-400 text-sm">CBT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-white/70 text-sm mb-5">
|
||||||
|
To view detailed incident reports, individual crime records, and location-specific information, please switch to the CBT data source.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Change Data Source
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full mt-6 pt-3 border-t border-emerald-500/20">
|
||||||
|
<p className="text-xs text-white/60">
|
||||||
|
The CBU (Crime By Unit) data provides insights at the district level, while CBT (Crime By Type) includes detailed incident-level information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Enhanced info card */}
|
{/* Enhanced info card */}
|
||||||
|
@ -93,7 +146,7 @@ export function SidebarIncidentsTab({
|
||||||
<span className="text-sidebar-foreground/70">{location}</span>
|
<span className="text-sidebar-foreground/70">{location}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 bg-sidebar-accent/30 p-2 rounded-lg">
|
<div className="flex items-center gap-2 bg-sidebar-accent/30 p-2 rounded-lg">
|
||||||
<AlertTriangle className="h-5 w-5 text-amber-400" />
|
<AlertTriangle className="h-5 w-5 text-emerald-400" />
|
||||||
<span>
|
<span>
|
||||||
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
|
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
|
||||||
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
|
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
|
||||||
|
@ -116,8 +169,8 @@ export function SidebarIncidentsTab({
|
||||||
<SystemStatusCard
|
<SystemStatusCard
|
||||||
title="Recent Cases"
|
title="Recent Cases"
|
||||||
status={`${crimeStats?.recentIncidents?.length || 0}`}
|
status={`${crimeStats?.recentIncidents?.length || 0}`}
|
||||||
statusIcon={<Clock className="h-4 w-4 text-amber-400" />}
|
statusIcon={<Clock className="h-4 w-4 text-emerald-400" />}
|
||||||
statusColor="text-amber-400"
|
statusColor="text-emerald-400"
|
||||||
updatedTime="Last 30 days"
|
updatedTime="Last 30 days"
|
||||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||||
borderColor="border-sidebar-border"
|
borderColor="border-sidebar-border"
|
||||||
|
@ -243,7 +296,7 @@ export function SidebarIncidentsTab({
|
||||||
<div key={monthKey} className="mb-5">
|
<div key={monthKey} className="mb-5">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Calendar className="h-3.5 w-3.5 text-amber-400" />
|
<Calendar className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
<h4 className="font-medium text-xs">{formatMonthKey(monthKey)}</h4>
|
<h4 className="font-medium text-xs">{formatMonthKey(monthKey)}</h4>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||||
|
|
|
@ -5,7 +5,11 @@ import { Separator } from "@/app/_components/ui/separator"
|
||||||
import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
|
import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
|
||||||
import { SidebarSection } from "../components/sidebar-section"
|
import { SidebarSection } from "../components/sidebar-section"
|
||||||
|
|
||||||
export function SidebarInfoTab() {
|
interface SidebarInfoTabProps {
|
||||||
|
sourceType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarInfoTab({ sourceType = "cbt" }: SidebarInfoTabProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarSection title="Map Legend" icon={<Layers className="h-4 w-4 text-green-400" />}>
|
<SidebarSection title="Map Legend" icon={<Layers className="h-4 w-4 text-green-400" />}>
|
||||||
|
@ -29,8 +33,12 @@ export function SidebarInfoTab() {
|
||||||
|
|
||||||
<Separator className="bg-white/20 my-3" />
|
<Separator className="bg-white/20 my-3" />
|
||||||
|
|
||||||
|
{/* Show different map markers based on source type */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium mb-2 text-sm">Map Markers</h4>
|
<h4 className="font-medium mb-2 text-sm">Map Markers</h4>
|
||||||
|
{sourceType === "cbt" ? (
|
||||||
|
// Detailed incidents for CBT
|
||||||
|
<>
|
||||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
<span>Individual Incident</span>
|
<span>Individual Incident</span>
|
||||||
|
@ -39,14 +47,31 @@ export function SidebarInfoTab() {
|
||||||
<div className="w-5 h-5 rounded-full bg-pink-400 flex items-center justify-center text-[10px] text-white">5</div>
|
<div className="w-5 h-5 rounded-full bg-pink-400 flex items-center justify-center text-[10px] text-white">5</div>
|
||||||
<span className="font-bold">Incident Cluster</span>
|
<span className="font-bold">Incident Cluster</span>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Simplified view for CBU
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-blue-400 flex items-center justify-center text-[10px] text-white">12</div>
|
||||||
|
<span className="font-bold">District Crime Count</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-1.5 text-white/70">
|
||||||
|
Shows aggregated crime counts by district. Size indicates relative crime volume.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
|
{/* Show layers info based on source type */}
|
||||||
<SidebarSection title="Map Layers" icon={<Map className="h-4 w-4 text-blue-400" />}>
|
<SidebarSection title="Map Layers" icon={<Map className="h-4 w-4 text-blue-400" />}>
|
||||||
<Card className="bg-gradient-to-r from-zinc-800/80 to-zinc-900/80 border border-white/10">
|
<Card className="bg-gradient-to-r from-zinc-800/80 to-zinc-900/80 border border-white/10">
|
||||||
<CardContent className="p-4 text-xs space-y-4">
|
<CardContent className="p-4 text-xs space-y-4">
|
||||||
|
{sourceType === "cbt" ? (
|
||||||
|
// Show all layers for CBT
|
||||||
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||||
<AlertCircle className="h-4 w-4 text-cyan-400" />
|
<AlertCircle className="h-4 w-4 text-cyan-400" />
|
||||||
|
@ -146,6 +171,37 @@ export function SidebarInfoTab() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Show limited layers info for CBU
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||||
|
<Box className="h-4 w-4 text-blue-400" />
|
||||||
|
<span>District Crime Data</span>
|
||||||
|
</h4>
|
||||||
|
<p className="text-white/70 pl-6">
|
||||||
|
Shows aggregated crime statistics by district. Each point represents the total crime count for a district.
|
||||||
|
Points are color-coded based on crime level: green (low), yellow (medium), red (high).
|
||||||
|
</p>
|
||||||
|
<p className="text-white/70 pl-6 mt-1">
|
||||||
|
The size of each point indicates the relative number of crimes reported in that district.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-6 pl-6 pt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: CRIME_RATE_COLORS.low }}></div>
|
||||||
|
<span>Low</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: CRIME_RATE_COLORS.medium }}></div>
|
||||||
|
<span>Medium</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: CRIME_RATE_COLORS.high }}></div>
|
||||||
|
<span>High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
@ -170,6 +226,12 @@ export function SidebarInfoTab() {
|
||||||
<span>Last Updated</span>
|
<span>Last Updated</span>
|
||||||
<span className="font-medium">June 18, 2024</span>
|
<span className="font-medium">June 18, 2024</span>
|
||||||
</div>
|
</div>
|
||||||
|
{sourceType && (
|
||||||
|
<div className="flex justify-between mt-1">
|
||||||
|
<span>Data Source</span>
|
||||||
|
<span className="font-medium">{sourceType.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
@ -203,6 +265,8 @@ export function SidebarInfoTab() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Show incident details help only for CBT */}
|
||||||
|
{sourceType === "cbt" && (
|
||||||
<div className="flex gap-3 items-start">
|
<div className="flex gap-3 items-start">
|
||||||
<div className="bg-emerald-900/50 p-1.5 rounded-md">
|
<div className="bg-emerald-900/50 p-1.5 rounded-md">
|
||||||
<AlertTriangle className="h-3.5 w-3.5 text-emerald-400" />
|
<AlertTriangle className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
@ -214,6 +278,7 @@ export function SidebarInfoTab() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Activity, Calendar, CheckCircle, AlertTriangle, LineChart, PieChart, FileText } from 'lucide-react'
|
import { Activity, Calendar, CheckCircle, AlertTriangle, LineChart, PieChart, FileText, BarChart2, TrendingUp, MapPin, Clock, AlertCircle, Info, ArrowRight, RefreshCw } from 'lucide-react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card"
|
||||||
import { Separator } from "@/app/_components/ui/separator"
|
import { Separator } from "@/app/_components/ui/separator"
|
||||||
import { cn } from "@/app/_lib/utils"
|
import { cn } from "@/app/_lib/utils"
|
||||||
|
@ -9,19 +9,275 @@ import { StatCard } from "../components/stat-card"
|
||||||
import { CrimeTypeCard, ICrimeTypeCardProps } from "../components/crime-type-card"
|
import { CrimeTypeCard, ICrimeTypeCardProps } from "../components/crime-type-card"
|
||||||
import { ICrimeAnalytics } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics'
|
import { ICrimeAnalytics } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics'
|
||||||
import { MONTHS } from '@/app/_utils/const/common'
|
import { MONTHS } from '@/app/_utils/const/common'
|
||||||
|
import { ICrimes } from '@/app/_utils/types/crimes'
|
||||||
|
import { Button } from "@/app/_components/ui/button"
|
||||||
|
|
||||||
interface ISidebarStatisticsTabProps {
|
interface ISidebarStatisticsTabProps {
|
||||||
crimeStats: ICrimeAnalytics
|
crimeStats: ICrimeAnalytics
|
||||||
selectedMonth?: number | "all"
|
selectedMonth?: number | "all"
|
||||||
selectedYear: number
|
selectedYear: number
|
||||||
|
sourceType?: string
|
||||||
|
crimes?: ICrimes[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Component for rendering bar chart for monthly trends
|
||||||
|
const MonthlyTrendChart = ({ monthlyData = Array(12).fill(0) }: { monthlyData: number[] }) => (
|
||||||
|
<div className="h-32 flex items-end gap-1 mt-2">
|
||||||
|
{monthlyData.map((count: number, i: number) => {
|
||||||
|
const maxCount = Math.max(...monthlyData);
|
||||||
|
const height = maxCount > 0 ? (count / maxCount) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-gradient-to-t from-emerald-600 to-green-400 w-full rounded-t-md"
|
||||||
|
style={{
|
||||||
|
height: `${Math.max(5, height)}%`,
|
||||||
|
opacity: 0.7 + (i / 24)
|
||||||
|
}}
|
||||||
|
title={`${getMonthName(i + 1)}: ${count} incidents`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Component for rendering yearly trend chart
|
||||||
|
const YearlyTrendChart = ({ crimes }: { crimes: ICrimes[] }) => {
|
||||||
|
const yearCounts = React.useMemo(() => {
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
const years = Array.from(new Set(crimes.map(c => c.year))).filter((y): y is number => typeof y === "number").sort();
|
||||||
|
years.forEach(year => {
|
||||||
|
counts.set(year, 0);
|
||||||
|
});
|
||||||
|
crimes.forEach(crime => {
|
||||||
|
if (crime.year) {
|
||||||
|
const currentCount = counts.get(crime.year) || 0;
|
||||||
|
counts.set(crime.year, currentCount + (crime.number_of_crime || 0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [crimes]);
|
||||||
|
|
||||||
|
const years = Array.from(yearCounts.keys()).sort();
|
||||||
|
const values = years.map(year => yearCounts.get(year) || 0);
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-32 text-white/50 text-sm">
|
||||||
|
No yearly data available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="h-32 flex flex-col justify-end">
|
||||||
|
<div className="flex justify-between items-end h-full">
|
||||||
|
{years.map((year, i) => {
|
||||||
|
const count = values[i];
|
||||||
|
const maxCount = Math.max(...values);
|
||||||
|
const height = maxCount > 0 ? (count / maxCount) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex flex-col items-center gap-1 w-full">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 w-8 rounded-t-md"
|
||||||
|
style={{
|
||||||
|
height: `${Math.max(5, height)}%`,
|
||||||
|
opacity: 0.6 + (i / 10)
|
||||||
|
}}
|
||||||
|
title={`${year}: ${count} incidents`}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-white/60">{year}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component for district distribution chart
|
||||||
|
const DistrictDistribution = ({ crimes, sourceType }: { crimes: ICrimes[], sourceType?: string }) => {
|
||||||
|
const districtData = React.useMemo(() => {
|
||||||
|
const districtCounts = new Map<string, { name: string, count: number }>();
|
||||||
|
crimes.forEach(crime => {
|
||||||
|
if (crime.district_id && crime.districts?.name) {
|
||||||
|
const districtId = crime.district_id;
|
||||||
|
const districtName = crime.districts.name;
|
||||||
|
const count = crime.number_of_crime || 0;
|
||||||
|
|
||||||
|
if (districtCounts.has(districtId)) {
|
||||||
|
const current = districtCounts.get(districtId)!;
|
||||||
|
districtCounts.set(districtId, {
|
||||||
|
name: districtName,
|
||||||
|
count: current.count + count
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
districtCounts.set(districtId, {
|
||||||
|
name: districtName,
|
||||||
|
count: count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedDistricts = Array.from(districtCounts.values())
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const totalIncidents = sortedDistricts.reduce((sum, district) => sum + district.count, 0);
|
||||||
|
|
||||||
|
return sortedDistricts.map(district => ({
|
||||||
|
name: district.name,
|
||||||
|
count: district.count,
|
||||||
|
percentage: Math.round((district.count / totalIncidents) * 100)
|
||||||
|
}));
|
||||||
|
}, [crimes]);
|
||||||
|
|
||||||
|
if (districtData.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-r from-indigo-900/30 to-indigo-800/20 border border-indigo-900/20">
|
||||||
|
<CardHeader className="p-3 pb-0">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4 text-indigo-400" />
|
||||||
|
Top Districts
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-white/60">Crime distribution by area</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3 pt-2 text-center text-white/50 text-sm">
|
||||||
|
No district data available
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-r from-indigo-900/30 to-indigo-800/20 border border-indigo-900/20">
|
||||||
|
<CardHeader className="p-3 pb-0">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4 text-indigo-400" />
|
||||||
|
Top Districts
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-white/60">Crime distribution by area</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3 pt-2">
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{districtData.map((district, index) => (
|
||||||
|
<div key={index} className="flex flex-col gap-1">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span>{district.name}</span>
|
||||||
|
<span className="font-medium">{district.count} incidents</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-indigo-500 rounded-full"
|
||||||
|
style={{ width: `${district.percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component for time of day distribution
|
||||||
|
const TimeOfDayDistribution = ({ crimes, sourceType }: { crimes: ICrimes[], sourceType?: string }) => {
|
||||||
|
const timeData = React.useMemo(() => {
|
||||||
|
const periods = [
|
||||||
|
{ name: "Morning (6AM-12PM)", start: 6, end: 11, color: "from-yellow-400 to-yellow-300" },
|
||||||
|
{ name: "Afternoon (12PM-6PM)", start: 12, end: 17, color: "from-orange-500 to-orange-400" },
|
||||||
|
{ name: "Evening (6PM-12AM)", start: 18, end: 23, color: "from-blue-600 to-blue-500" },
|
||||||
|
{ name: "Night (12AM-6AM)", start: 0, end: 5, color: "from-gray-800 to-gray-700" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const counts = periods.map(p => ({
|
||||||
|
period: p.name,
|
||||||
|
count: 0,
|
||||||
|
percentage: 0,
|
||||||
|
color: p.color
|
||||||
|
}));
|
||||||
|
|
||||||
|
let totalCounted = 0;
|
||||||
|
crimes.forEach(crime => {
|
||||||
|
if (!crime.crime_incidents) return;
|
||||||
|
|
||||||
|
crime.crime_incidents.forEach((incident) => {
|
||||||
|
if (incident.timestamp) {
|
||||||
|
const date = new Date(incident.timestamp);
|
||||||
|
const hour = date.getHours();
|
||||||
|
|
||||||
|
for (let i = 0; i < periods.length; i++) {
|
||||||
|
if (hour >= periods[i].start && hour <= periods[i].end) {
|
||||||
|
counts[i].count++;
|
||||||
|
totalCounted++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalCounted > 0) {
|
||||||
|
counts.forEach(item => {
|
||||||
|
item.percentage = Math.round((item.count / totalCounted) * 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCounted === 0) {
|
||||||
|
counts[0].percentage = 15;
|
||||||
|
counts[1].percentage = 25;
|
||||||
|
counts[2].percentage = 40;
|
||||||
|
counts[3].percentage = 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}, [crimes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-r from-purple-900/30 to-purple-800/20 border border-purple-900/20">
|
||||||
|
<CardHeader className="p-3 pb-0">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-purple-400" />
|
||||||
|
Time of Day
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-white/60">When incidents occur</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3 pt-2">
|
||||||
|
<div className="h-8 rounded-lg overflow-hidden flex mt-2">
|
||||||
|
{timeData.map((time, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`bg-gradient-to-r ${time.color} h-full`}
|
||||||
|
style={{ width: `${time.percentage}%` }}
|
||||||
|
title={`${time.period}: ${time.percentage}%`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs mt-1 text-white/70">
|
||||||
|
{timeData.map((time, index) => (
|
||||||
|
<div key={index} className="flex flex-col items-center">
|
||||||
|
<div className={`w-3 h-3 rounded-full bg-gradient-to-r ${time.color}`} />
|
||||||
|
<span className="text-[9px] mt-0.5">{time.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function SidebarStatisticsTab({
|
export function SidebarStatisticsTab({
|
||||||
crimeStats,
|
crimeStats,
|
||||||
selectedMonth = "all",
|
selectedMonth = "all",
|
||||||
selectedYear
|
selectedYear,
|
||||||
}: ISidebarStatisticsTabProps) {
|
sourceType = "cbt",
|
||||||
|
crimes = []
|
||||||
|
}: ISidebarStatisticsTabProps & { crimes?: ICrimes[] }) {
|
||||||
const topCategories = crimeStats.categoryCounts ?
|
const topCategories = crimeStats.categoryCounts ?
|
||||||
Object.entries(crimeStats.categoryCounts)
|
Object.entries(crimeStats.categoryCounts)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
@ -31,8 +287,64 @@ export function SidebarStatisticsTab({
|
||||||
return { type, count, percentage }
|
return { type, count, percentage }
|
||||||
}) : []
|
}) : []
|
||||||
|
|
||||||
|
|
||||||
|
// If source type is CBU, display warning instead of regular content
|
||||||
|
if (sourceType === "cbu") {
|
||||||
|
return (
|
||||||
|
<Card className="bg-gradient-to-r from-emerald-900/20 to-emerald-800/10 border border-emerald-500/30">
|
||||||
|
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||||
|
<div className="bg-emerald-500/20 rounded-full p-3 mb-3">
|
||||||
|
<AlertTriangle className="h-8 w-8 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Limited Data View</h3>
|
||||||
|
|
||||||
|
<p className="text-white/80 mb-4">
|
||||||
|
The CBU data source only provides aggregated statistics without detailed incident information.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-black/20 rounded-lg p-3 w-full mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-white/60 text-sm">Current Data Source:</span>
|
||||||
|
<span className="font-medium text-emerald-400 text-sm">CBU</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-white/60 text-sm">Recommended:</span>
|
||||||
|
<span className="font-medium text-blue-400 text-sm">CBT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-white/70 text-sm mb-5">
|
||||||
|
To view detailed incident reports, individual crime records, and location-specific information, please switch to the CBT data source.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Change Data Source
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full mt-6 pt-3 border-t border-emerald-500/20">
|
||||||
|
<p className="text-xs text-white/60">
|
||||||
|
The CBU (Crime By Unit) data provides insights at the district level, while CBT (Crime By Type) includes detailed incident-level information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<Card className="bg-gradient-to-r from-sidebar-primary/30 to-sidebar-primary/20 border border-sidebar-primary/20 overflow-hidden">
|
<Card className="bg-gradient-to-r from-sidebar-primary/30 to-sidebar-primary/20 border border-sidebar-primary/20 overflow-hidden">
|
||||||
<CardHeader className="p-3 pb-0">
|
<CardHeader className="p-3 pb-0">
|
||||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
@ -42,27 +354,7 @@ export function SidebarStatisticsTab({
|
||||||
<CardDescription className="text-xs text-white/60">{selectedYear}</CardDescription>
|
<CardDescription className="text-xs text-white/60">{selectedYear}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<div className="h-32 flex items-end gap-1 mt-2">
|
<MonthlyTrendChart monthlyData={crimeStats.incidentsByMonth} />
|
||||||
{crimeStats.incidentsByMonth.map((count: number, i: number) => {
|
|
||||||
const maxCount = Math.max(...crimeStats.incidentsByMonth)
|
|
||||||
const height = maxCount > 0 ? (count / maxCount) * 100 : 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
"bg-gradient-to-t from-emerald-600 to-green-400 w-full rounded-t-md",
|
|
||||||
selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "from-amber-500 to-amber-400" : ""
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
height: `${Math.max(5, height)}%`,
|
|
||||||
opacity: 0.7 + (i / 24)
|
|
||||||
}}
|
|
||||||
title={`${getMonthName(i + 1)}: ${count} incidents`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between mt-2 text-[10px] text-white/60">
|
<div className="flex justify-between mt-2 text-[10px] text-white/60">
|
||||||
{MONTHS.map((month, i) => (
|
{MONTHS.map((month, i) => (
|
||||||
<span key={i} className={cn("w-1/12 text-center", selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "text-amber-400" : "")}>
|
<span key={i} className={cn("w-1/12 text-center", selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "text-amber-400" : "")}>
|
||||||
|
@ -109,6 +401,29 @@ export function SidebarStatisticsTab({
|
||||||
</div>
|
</div>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-r from-blue-900/30 to-blue-800/20 border border-blue-900/20 overflow-hidden">
|
||||||
|
<CardHeader className="p-3 pb-0">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<BarChart2 className="h-4 w-4 text-blue-400" />
|
||||||
|
Yearly Trends
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-white/60">Historical Overview</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<YearlyTrendChart crimes={crimes} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator className="bg-white/20 my-4" />
|
||||||
|
|
||||||
|
<TimeOfDayDistribution crimes={crimes} sourceType={sourceType} />
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<DistrictDistribution crimes={crimes} sourceType={sourceType} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sourceType === "cbt" && (
|
||||||
|
<>
|
||||||
<Separator className="bg-white/20 my-4" />
|
<Separator className="bg-white/20 my-4" />
|
||||||
|
|
||||||
<SidebarSection title="Most Common Crimes" icon={<PieChart className="h-4 w-4 text-amber-400" />}>
|
<SidebarSection title="Most Common Crimes" icon={<PieChart className="h-4 w-4 text-amber-400" />}>
|
||||||
|
@ -136,5 +451,7 @@ export function SidebarStatisticsTab({
|
||||||
</div>
|
</div>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/app/_components/ui/select"
|
||||||
|
import { cn } from "@/app/_lib/utils"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Skeleton } from "../../ui/skeleton"
|
||||||
|
|
||||||
|
interface SourceTypeSelectorProps {
|
||||||
|
selectedSourceType: string
|
||||||
|
onSourceTypeChange: (sourceType: string) => void
|
||||||
|
availableSourceTypes: string[]
|
||||||
|
className?: string
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SourceTypeSelector({
|
||||||
|
selectedSourceType,
|
||||||
|
onSourceTypeChange,
|
||||||
|
availableSourceTypes,
|
||||||
|
className,
|
||||||
|
isLoading = false,
|
||||||
|
}: SourceTypeSelectorProps) {
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This will ensure that the document is only used in the client-side context
|
||||||
|
setIsClient(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const container = isClient ? document.getElementById("root") : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="mapboxgl-category-selector">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-8">
|
||||||
|
<Skeleton className="h-full w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={selectedSourceType}
|
||||||
|
onValueChange={(value) => onSourceTypeChange(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={className}>
|
||||||
|
<SelectValue placeholder="Crime Category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
container={containerRef.current || container || undefined}
|
||||||
|
style={{ zIndex: 2000 }}
|
||||||
|
className={`${className}`}
|
||||||
|
>
|
||||||
|
{availableSourceTypes.map((category) => (
|
||||||
|
<SelectItem key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,19 +3,19 @@
|
||||||
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 { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover"
|
import { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover"
|
||||||
import { ChevronDown, Layers, Siren } from "lucide-react"
|
import { ChevronDown, Siren } from "lucide-react"
|
||||||
import { IconMessage } from "@tabler/icons-react"
|
import { IconMessage } from "@tabler/icons-react"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { ITooltips } from "./tooltips"
|
import type { ITooltips } from "./tooltips"
|
||||||
import MonthSelector from "../month-selector"
|
import MonthSelector from "../month-selector"
|
||||||
import YearSelector from "../year-selector"
|
import YearSelector from "../year-selector"
|
||||||
import CategorySelector from "../category-selector"
|
import CategorySelector from "../category-selector"
|
||||||
|
import SourceTypeSelector from "../source-type-selector"
|
||||||
|
|
||||||
// Define the additional tools and features
|
// Define the additional tools and features
|
||||||
const additionalTooltips = [
|
const additionalTooltips = [
|
||||||
{ id: "reports" as ITooltips, icon: <IconMessage size={20} />, label: "Police Report" },
|
{ id: "reports" as ITooltips, icon: <IconMessage size={20} />, label: "Police Report" },
|
||||||
{ id: "layers" as ITooltips, icon: <Layers size={20} />, label: "Map Layers" },
|
|
||||||
{ id: "alerts" as ITooltips, icon: <Siren size={20} />, label: "Active Alerts" },
|
{ id: "alerts" as ITooltips, icon: <Siren size={20} />, label: "Active Alerts" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -28,7 +28,10 @@ interface AdditionalTooltipsProps {
|
||||||
setSelectedMonth: (month: number | "all") => void
|
setSelectedMonth: (month: number | "all") => void
|
||||||
selectedCategory: string | "all"
|
selectedCategory: string | "all"
|
||||||
setSelectedCategory: (category: string | "all") => void
|
setSelectedCategory: (category: string | "all") => void
|
||||||
|
selectedSourceType: string
|
||||||
|
setSelectedSourceType: (sourceType: string) => void
|
||||||
availableYears?: (number | null)[]
|
availableYears?: (number | null)[]
|
||||||
|
availableSourceTypes?: string[]
|
||||||
categories?: string[]
|
categories?: string[]
|
||||||
panicButtonTriggered?: boolean
|
panicButtonTriggered?: boolean
|
||||||
}
|
}
|
||||||
|
@ -42,7 +45,10 @@ export default function AdditionalTooltips({
|
||||||
setSelectedMonth,
|
setSelectedMonth,
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
setSelectedCategory,
|
setSelectedCategory,
|
||||||
availableYears = [2022, 2023, 2024],
|
selectedSourceType = "cbu",
|
||||||
|
setSelectedSourceType,
|
||||||
|
availableYears = [],
|
||||||
|
availableSourceTypes = [],
|
||||||
categories = [],
|
categories = [],
|
||||||
panicButtonTriggered = false,
|
panicButtonTriggered = false,
|
||||||
}: AdditionalTooltipsProps) {
|
}: AdditionalTooltipsProps) {
|
||||||
|
@ -54,39 +60,52 @@ export default function AdditionalTooltips({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (panicButtonTriggered && activeControl !== "alerts" && onControlChange) {
|
if (panicButtonTriggered && activeControl !== "alerts" && onControlChange) {
|
||||||
onControlChange("alerts");
|
onControlChange("alerts")
|
||||||
}
|
}
|
||||||
}, [panicButtonTriggered, activeControl, onControlChange]);
|
}, [panicButtonTriggered, activeControl, onControlChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true);
|
setIsClient(true)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
|
const isControlDisabled = (controlId: ITooltips) => {
|
||||||
|
// When source type is CBU, disable all controls except for layers
|
||||||
|
return selectedSourceType === "cbu" && controlId !== "layers"
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={containerRef} className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
<div ref={containerRef} className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{additionalTooltips.map((control) => (
|
{additionalTooltips.map((control) => {
|
||||||
|
const isButtonDisabled = isControlDisabled(control.id)
|
||||||
|
|
||||||
|
return (
|
||||||
<Tooltip key={control.id}>
|
<Tooltip key={control.id}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={activeControl === control.id ? "default" : "ghost"}
|
variant={activeControl === control.id ? "default" : "ghost"}
|
||||||
size="medium"
|
size="medium"
|
||||||
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
className={`h-8 w-8 rounded-md ${isButtonDisabled
|
||||||
|
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
|
||||||
|
: activeControl === control.id
|
||||||
? "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"
|
||||||
} ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`}
|
} ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`}
|
||||||
onClick={() => onControlChange?.(control.id)}
|
onClick={() => onControlChange?.(control.id)}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
aria-disabled={isButtonDisabled}
|
||||||
>
|
>
|
||||||
{control.icon}
|
{control.icon}
|
||||||
<span className="sr-only">{control.label}</span>
|
<span className="sr-only">{control.label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<p>{control.label}</p>
|
<p>{isButtonDisabled ? "Not available for CBU data" : control.label}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
|
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
|
||||||
|
@ -108,6 +127,15 @@ export default function AdditionalTooltips({
|
||||||
style={{ zIndex: 2000 }}
|
style={{ zIndex: 2000 }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs w-16">Source:</span>
|
||||||
|
<SourceTypeSelector
|
||||||
|
availableSourceTypes={availableSourceTypes}
|
||||||
|
selectedSourceType={selectedSourceType}
|
||||||
|
onSourceTypeChange={setSelectedSourceType}
|
||||||
|
className="w-[180px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs w-16">Year:</span>
|
<span className="text-xs w-16">Year:</span>
|
||||||
<YearSelector
|
<YearSelector
|
||||||
|
@ -143,18 +171,24 @@ export default function AdditionalTooltips({
|
||||||
|
|
||||||
{showSelectors && (
|
{showSelectors && (
|
||||||
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
|
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
|
||||||
|
<SourceTypeSelector
|
||||||
|
availableSourceTypes={availableSourceTypes}
|
||||||
|
selectedSourceType={selectedSourceType}
|
||||||
|
onSourceTypeChange={setSelectedSourceType}
|
||||||
|
className="w-[80px]"
|
||||||
|
/>
|
||||||
<YearSelector
|
<YearSelector
|
||||||
availableYears={availableYears}
|
availableYears={availableYears}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
onYearChange={setSelectedYear}
|
onYearChange={setSelectedYear}
|
||||||
className="w-[100px]"
|
className="w-[80px]"
|
||||||
/>
|
/>
|
||||||
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[100px]" />
|
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[80px]" />
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
categories={categories}
|
categories={categories}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
onCategoryChange={setSelectedCategory}
|
onCategoryChange={setSelectedCategory}
|
||||||
className="w-[100px]"
|
className="w-[80px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -2,10 +2,9 @@
|
||||||
|
|
||||||
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, Thermometer, Shield, Users } from "lucide-react"
|
import { AlertTriangle, Building, Car, Thermometer } from "lucide-react"
|
||||||
import { ITooltips } from "./tooltips"
|
import type { ITooltips } from "./tooltips"
|
||||||
import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react"
|
import { IconChartBubble, IconClock } from "@tabler/icons-react"
|
||||||
|
|
||||||
|
|
||||||
// Define the primary crime data controls
|
// Define the primary crime data controls
|
||||||
const crimeTooltips = [
|
const crimeTooltips = [
|
||||||
|
@ -20,42 +19,60 @@ const crimeTooltips = [
|
||||||
interface CrimeTooltipsProps {
|
interface CrimeTooltipsProps {
|
||||||
activeControl?: string
|
activeControl?: string
|
||||||
onControlChange?: (controlId: ITooltips) => void
|
onControlChange?: (controlId: ITooltips) => void
|
||||||
|
sourceType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrimeTooltips({ activeControl, onControlChange }: CrimeTooltipsProps) {
|
export default function CrimeTooltips({ activeControl, onControlChange, sourceType = "cbt" }: CrimeTooltipsProps) {
|
||||||
const handleControlClick = (controlId: ITooltips) => {
|
const handleControlClick = (controlId: ITooltips) => {
|
||||||
console.log("Clicked control:", controlId);
|
// If control is disabled, don't do anything
|
||||||
|
if (isDisabled(controlId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Force the value to be set when clicking
|
// Force the value to be set when clicking
|
||||||
if (onControlChange) {
|
if (onControlChange) {
|
||||||
onControlChange(controlId);
|
onControlChange(controlId)
|
||||||
console.log("Control changed to:", controlId);
|
console.log("Control changed to:", controlId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which controls should be disabled based on source type
|
||||||
|
const isDisabled = (controlId: ITooltips) => {
|
||||||
|
return sourceType === "cbu" && controlId !== "clusters"
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
||||||
{crimeTooltips.map((control) => (
|
{crimeTooltips.map((control) => {
|
||||||
|
const isButtonDisabled = isDisabled(control.id)
|
||||||
|
|
||||||
|
return (
|
||||||
<Tooltip key={control.id}>
|
<Tooltip key={control.id}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={activeControl === control.id ? "default" : "ghost"}
|
variant={activeControl === control.id ? "default" : "ghost"}
|
||||||
size="medium"
|
size="medium"
|
||||||
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
className={`h-8 w-8 rounded-md ${isButtonDisabled
|
||||||
|
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
|
||||||
|
: activeControl === control.id
|
||||||
? "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={() => handleControlClick(control.id)}
|
onClick={() => handleControlClick(control.id)}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
aria-disabled={isButtonDisabled}
|
||||||
>
|
>
|
||||||
{control.icon}
|
{control.icon}
|
||||||
<span className="sr-only">{control.label}</span>
|
<span className="sr-only">{control.label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<p>{control.label}</p>
|
<p>{isButtonDisabled ? "Not available for CBU data" : control.label}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -2,15 +2,25 @@
|
||||||
|
|
||||||
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 { Search, XCircle, Info, ExternalLink, Calendar, MapPin, MessageSquare, FileText, Map, FolderOpen } from 'lucide-react'
|
import {
|
||||||
|
Search,
|
||||||
|
XCircle,
|
||||||
|
Info,
|
||||||
|
ExternalLink,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
MessageSquare,
|
||||||
|
FileText,
|
||||||
|
Map,
|
||||||
|
FolderOpen,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
import ActionSearchBar from "@/app/_components/action-search-bar"
|
import ActionSearchBar from "@/app/_components/action-search-bar"
|
||||||
import { Card } from "@/app/_components/ui/card"
|
import { Card } from "@/app/_components/ui/card"
|
||||||
import { format } from 'date-fns'
|
import { format } from "date-fns"
|
||||||
import { ITooltips } from "./tooltips"
|
import type { ITooltips } from "./tooltips"
|
||||||
import { $Enums } from "@prisma/client"
|
|
||||||
|
|
||||||
// Define types based on the crime data structure
|
// Define types based on the crime data structure
|
||||||
interface ICrimeIncident {
|
interface ICrimeIncident {
|
||||||
|
@ -19,10 +29,10 @@ interface ICrimeIncident {
|
||||||
description: string
|
description: string
|
||||||
status: string
|
status: string
|
||||||
locations: {
|
locations: {
|
||||||
address: string;
|
address: string
|
||||||
longitude: number;
|
longitude: number
|
||||||
latitude: number;
|
latitude: number
|
||||||
},
|
}
|
||||||
crime_categories: {
|
crime_categories: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
@ -88,9 +98,15 @@ interface SearchTooltipProps {
|
||||||
onControlChange?: (controlId: ITooltips) => void
|
onControlChange?: (controlId: ITooltips) => void
|
||||||
activeControl?: string
|
activeControl?: string
|
||||||
crimes?: ICrime[]
|
crimes?: ICrime[]
|
||||||
|
sourceType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SearchTooltip({ onControlChange, activeControl, crimes = [] }: SearchTooltipProps) {
|
export default function SearchTooltip({
|
||||||
|
onControlChange,
|
||||||
|
activeControl,
|
||||||
|
crimes = [],
|
||||||
|
sourceType = "cbt",
|
||||||
|
}: SearchTooltipProps) {
|
||||||
const [showSearch, setShowSearch] = useState(false)
|
const [showSearch, setShowSearch] = useState(false)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [selectedSearchType, setSelectedSearchType] = useState<string | null>(null)
|
const [selectedSearchType, setSelectedSearchType] = useState<string | null>(null)
|
||||||
|
@ -100,164 +116,169 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
const [selectedSuggestion, setSelectedSuggestion] = useState<ICrimeIncident | null>(null)
|
const [selectedSuggestion, setSelectedSuggestion] = useState<ICrimeIncident | null>(null)
|
||||||
const [showInfoBox, setShowInfoBox] = useState(false)
|
const [showInfoBox, setShowInfoBox] = useState(false)
|
||||||
|
|
||||||
|
// Check if search is disabled based on source type
|
||||||
|
const isSearchDisabled = sourceType === "cbu"
|
||||||
|
|
||||||
// Limit results to prevent performance issues
|
// Limit results to prevent performance issues
|
||||||
const MAX_RESULTS = 50;
|
const MAX_RESULTS = 50
|
||||||
|
|
||||||
// Extract all incidents from crimes data
|
// Extract all incidents from crimes data
|
||||||
const allIncidents = crimes.flatMap(crime =>
|
const allIncidents = crimes.flatMap((crime) =>
|
||||||
crime.crime_incidents.map(incident => ({
|
crime.crime_incidents.map((incident) => ({
|
||||||
...incident,
|
...incident,
|
||||||
district: crime.districts?.name || '',
|
district: crime.districts?.name || "",
|
||||||
year: crime.year,
|
year: crime.year,
|
||||||
month: crime.month
|
month: crime.month,
|
||||||
}))
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showSearch && searchInputRef.current) {
|
if (showSearch && searchInputRef.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus()
|
||||||
}, 100);
|
}, 100)
|
||||||
}
|
}
|
||||||
}, [showSearch]);
|
}, [showSearch])
|
||||||
|
|
||||||
const handleSearchTypeSelect = (actionId: string) => {
|
const handleSearchTypeSelect = (actionId: string) => {
|
||||||
const selectedAction = ACTIONS.find(action => action.id === actionId);
|
const selectedAction = ACTIONS.find((action) => action.id === actionId)
|
||||||
if (selectedAction) {
|
if (selectedAction) {
|
||||||
setSelectedSearchType(actionId);
|
setSelectedSearchType(actionId)
|
||||||
|
|
||||||
const prefix = selectedAction.prefix || "";
|
const prefix = selectedAction.prefix || ""
|
||||||
setSearchValue(prefix);
|
setSearchValue(prefix)
|
||||||
setIsInputValid(true);
|
setIsInputValid(true)
|
||||||
|
|
||||||
// Initial suggestions based on the selected search type
|
// Initial suggestions based on the selected search type
|
||||||
let initialSuggestions: ICrimeIncident[] = [];
|
let initialSuggestions: ICrimeIncident[] = []
|
||||||
|
|
||||||
if (actionId === 'incident_id') {
|
if (actionId === "incident_id") {
|
||||||
initialSuggestions = allIncidents.slice(0, MAX_RESULTS); // Limit to 50 results initially
|
initialSuggestions = allIncidents.slice(0, MAX_RESULTS) // Limit to 50 results initially
|
||||||
} else if (actionId === 'description' || actionId === 'locations.address') {
|
} else if (actionId === "description" || actionId === "locations.address") {
|
||||||
initialSuggestions = allIncidents.slice(0, MAX_RESULTS);
|
initialSuggestions = allIncidents.slice(0, MAX_RESULTS)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set suggestions in the next tick
|
// Set suggestions in the next tick
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSuggestions(initialSuggestions);
|
setSuggestions(initialSuggestions)
|
||||||
}, 0);
|
}, 0)
|
||||||
|
|
||||||
// Focus and position cursor after prefix
|
// Focus and position cursor after prefix
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (searchInputRef.current) {
|
if (searchInputRef.current) {
|
||||||
searchInputRef.current.focus();
|
searchInputRef.current.focus()
|
||||||
searchInputRef.current.selectionStart = prefix.length;
|
searchInputRef.current.selectionStart = prefix.length
|
||||||
searchInputRef.current.selectionEnd = prefix.length;
|
searchInputRef.current.selectionEnd = prefix.length
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter suggestions based on search type and search text
|
// Filter suggestions based on search type and search text
|
||||||
const filterSuggestions = (searchType: string, searchText: string): ICrimeIncident[] => {
|
const filterSuggestions = (searchType: string, searchText: string): ICrimeIncident[] => {
|
||||||
let filtered: ICrimeIncident[] = [];
|
let filtered: ICrimeIncident[] = []
|
||||||
|
|
||||||
if (searchType === 'incident_id') {
|
if (searchType === "incident_id") {
|
||||||
if (!searchText || searchText === 'CI-') {
|
if (!searchText || searchText === "CI-") {
|
||||||
filtered = allIncidents.slice(0, MAX_RESULTS);
|
filtered = allIncidents.slice(0, MAX_RESULTS)
|
||||||
} else {
|
} else {
|
||||||
filtered = allIncidents.filter(item =>
|
filtered = allIncidents
|
||||||
item.id.toLowerCase().includes(searchText.toLowerCase())
|
.filter((item) => item.id.toLowerCase().includes(searchText.toLowerCase()))
|
||||||
).slice(0, MAX_RESULTS);
|
.slice(0, MAX_RESULTS)
|
||||||
}
|
}
|
||||||
}
|
} else if (searchType === "description") {
|
||||||
else if (searchType === 'description') {
|
|
||||||
if (!searchText) {
|
if (!searchText) {
|
||||||
filtered = allIncidents.slice(0, MAX_RESULTS);
|
filtered = allIncidents.slice(0, MAX_RESULTS)
|
||||||
} else {
|
} else {
|
||||||
filtered = allIncidents.filter(item =>
|
filtered = allIncidents
|
||||||
item.description.toLowerCase().includes(searchText.toLowerCase())
|
.filter((item) => item.description.toLowerCase().includes(searchText.toLowerCase()))
|
||||||
).slice(0, MAX_RESULTS);
|
.slice(0, MAX_RESULTS)
|
||||||
}
|
}
|
||||||
}
|
} else if (searchType === "locations.address") {
|
||||||
else if (searchType === 'locations.address') {
|
|
||||||
if (!searchText) {
|
if (!searchText) {
|
||||||
filtered = allIncidents.slice(0, MAX_RESULTS);
|
filtered = allIncidents.slice(0, MAX_RESULTS)
|
||||||
} else {
|
} else {
|
||||||
filtered = allIncidents.filter(item =>
|
filtered = allIncidents
|
||||||
item.locations.address && item.locations.address.toLowerCase().includes(searchText.toLowerCase())
|
.filter(
|
||||||
).slice(0, MAX_RESULTS);
|
(item) => item.locations.address && item.locations.address.toLowerCase().includes(searchText.toLowerCase()),
|
||||||
|
)
|
||||||
|
.slice(0, MAX_RESULTS)
|
||||||
}
|
}
|
||||||
}
|
} else if (searchType === "coordinates") {
|
||||||
else if (searchType === 'coordinates') {
|
|
||||||
if (!searchText) {
|
if (!searchText) {
|
||||||
filtered = allIncidents.filter(item => item.locations.latitude !== undefined && item.locations.longitude !== undefined)
|
filtered = allIncidents
|
||||||
.slice(0, MAX_RESULTS);
|
.filter((item) => item.locations.latitude !== undefined && item.locations.longitude !== undefined)
|
||||||
|
.slice(0, MAX_RESULTS)
|
||||||
} else {
|
} else {
|
||||||
// For coordinates, we'd typically do a proximity search
|
// For coordinates, we'd typically do a proximity search
|
||||||
// This is a simple implementation for demo purposes
|
// This is a simple implementation for demo purposes
|
||||||
filtered = allIncidents.filter(item =>
|
filtered = allIncidents
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
item.locations.latitude !== undefined &&
|
item.locations.latitude !== undefined &&
|
||||||
item.locations.longitude !== undefined &&
|
item.locations.longitude !== undefined &&
|
||||||
`${item.locations.latitude}, ${item.locations.longitude}`.includes(searchText)
|
`${item.locations.latitude}, ${item.locations.longitude}`.includes(searchText),
|
||||||
).slice(0, MAX_RESULTS);
|
)
|
||||||
|
.slice(0, MAX_RESULTS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
const currentSearchType = selectedSearchType ?
|
const currentSearchType = selectedSearchType ? ACTIONS.find((action) => action.id === selectedSearchType) : null
|
||||||
ACTIONS.find(action => action.id === selectedSearchType) : null;
|
|
||||||
|
|
||||||
if (currentSearchType?.prefix && currentSearchType.prefix.length > 0) {
|
if (currentSearchType?.prefix && currentSearchType.prefix.length > 0) {
|
||||||
if (!value.startsWith(currentSearchType.prefix)) {
|
if (!value.startsWith(currentSearchType.prefix)) {
|
||||||
value = currentSearchType.prefix;
|
value = currentSearchType.prefix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearchValue(value);
|
setSearchValue(value)
|
||||||
|
|
||||||
if (currentSearchType?.regex) {
|
if (currentSearchType?.regex) {
|
||||||
if (!value || value === currentSearchType.prefix) {
|
if (!value || value === currentSearchType.prefix) {
|
||||||
setIsInputValid(true);
|
setIsInputValid(true)
|
||||||
} else {
|
} else {
|
||||||
setIsInputValid(currentSearchType.regex.test(value));
|
setIsInputValid(currentSearchType.regex.test(value))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsInputValid(true);
|
setIsInputValid(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedSearchType) {
|
if (!selectedSearchType) {
|
||||||
setSuggestions([]);
|
setSuggestions([])
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter suggestions based on search input
|
// Filter suggestions based on search input
|
||||||
setSuggestions(filterSuggestions(selectedSearchType, value));
|
setSuggestions(filterSuggestions(selectedSearchType, value))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClearSearchType = () => {
|
const handleClearSearchType = () => {
|
||||||
setSelectedSearchType(null);
|
setSelectedSearchType(null)
|
||||||
setSearchValue("");
|
setSearchValue("")
|
||||||
setSuggestions([]);
|
setSuggestions([])
|
||||||
if (searchInputRef.current) {
|
if (searchInputRef.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus()
|
||||||
}, 50);
|
}, 50)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestionSelect = (incident: ICrimeIncident) => {
|
const handleSuggestionSelect = (incident: ICrimeIncident) => {
|
||||||
setSearchValue(incident.id);
|
setSearchValue(incident.id)
|
||||||
setSuggestions([]);
|
setSuggestions([])
|
||||||
setSelectedSuggestion(incident);
|
setSelectedSuggestion(incident)
|
||||||
setShowInfoBox(true);
|
setShowInfoBox(true)
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleFlyToIncident = () => {
|
const handleFlyToIncident = () => {
|
||||||
if (!selectedSuggestion || !selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude) return;
|
if (!selectedSuggestion || !selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude) return
|
||||||
|
|
||||||
// Dispatch mapbox_fly_to event to the main map canvas only
|
// Dispatch mapbox_fly_to event to the main map canvas only
|
||||||
const flyToMapEvent = new CustomEvent('mapbox_fly_to', {
|
const flyToMapEvent = new CustomEvent("mapbox_fly_to", {
|
||||||
detail: {
|
detail: {
|
||||||
longitude: selectedSuggestion.locations.longitude,
|
longitude: selectedSuggestion.locations.longitude,
|
||||||
latitude: selectedSuggestion.locations.latitude,
|
latitude: selectedSuggestion.locations.latitude,
|
||||||
|
@ -266,19 +287,19 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
pitch: 45,
|
pitch: 45,
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
},
|
},
|
||||||
bubbles: true
|
bubbles: true,
|
||||||
});
|
})
|
||||||
|
|
||||||
// Find the main map canvas and dispatch event there
|
// Find the main map canvas and dispatch event there
|
||||||
const mapCanvas = document.querySelector('.mapboxgl-canvas');
|
const mapCanvas = document.querySelector(".mapboxgl-canvas")
|
||||||
if (mapCanvas) {
|
if (mapCanvas) {
|
||||||
mapCanvas.dispatchEvent(flyToMapEvent);
|
mapCanvas.dispatchEvent(flyToMapEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the fly animation to complete before showing the popup
|
// Wait for the fly animation to complete before showing the popup
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Then trigger the incident_click event to show the popup
|
// Then trigger the incident_click event to show the popup
|
||||||
const incidentEvent = new CustomEvent('incident_click', {
|
const incidentEvent = new CustomEvent("incident_click", {
|
||||||
detail: {
|
detail: {
|
||||||
id: selectedSuggestion.id,
|
id: selectedSuggestion.id,
|
||||||
longitude: selectedSuggestion.locations.longitude,
|
longitude: selectedSuggestion.locations.longitude,
|
||||||
|
@ -286,37 +307,39 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
description: selectedSuggestion.description,
|
description: selectedSuggestion.description,
|
||||||
status: selectedSuggestion.status,
|
status: selectedSuggestion.status,
|
||||||
timestamp: selectedSuggestion.timestamp,
|
timestamp: selectedSuggestion.timestamp,
|
||||||
crime_categories: selectedSuggestion.crime_categories
|
crime_categories: selectedSuggestion.crime_categories,
|
||||||
},
|
},
|
||||||
bubbles: true
|
bubbles: true,
|
||||||
});
|
})
|
||||||
|
|
||||||
document.dispatchEvent(incidentEvent);
|
document.dispatchEvent(incidentEvent)
|
||||||
}, 2100); // Slightly longer than the fly animation duration
|
}, 2100) // Slightly longer than the fly animation duration
|
||||||
|
|
||||||
setShowInfoBox(false);
|
setShowInfoBox(false)
|
||||||
setSelectedSuggestion(null);
|
setSelectedSuggestion(null)
|
||||||
toggleSearch();
|
toggleSearch()
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleCloseInfoBox = () => {
|
const handleCloseInfoBox = () => {
|
||||||
setShowInfoBox(false);
|
setShowInfoBox(false)
|
||||||
setSelectedSuggestion(null);
|
setSelectedSuggestion(null)
|
||||||
|
|
||||||
// Restore original suggestions
|
// Restore original suggestions
|
||||||
if (selectedSearchType) {
|
if (selectedSearchType) {
|
||||||
const initialSuggestions = filterSuggestions(selectedSearchType, searchValue);
|
const initialSuggestions = filterSuggestions(selectedSearchType, searchValue)
|
||||||
setSuggestions(initialSuggestions);
|
setSuggestions(initialSuggestions)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSearch = () => {
|
const toggleSearch = () => {
|
||||||
|
if (isSearchDisabled) return
|
||||||
|
|
||||||
setShowSearch(!showSearch)
|
setShowSearch(!showSearch)
|
||||||
if (!showSearch && onControlChange) {
|
if (!showSearch && onControlChange) {
|
||||||
onControlChange("search" as ITooltips)
|
onControlChange("search" as ITooltips)
|
||||||
setSelectedSearchType(null);
|
setSelectedSearchType(null)
|
||||||
setSearchValue("");
|
setSearchValue("")
|
||||||
setSuggestions([]);
|
setSuggestions([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -324,13 +347,13 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
const formatIncidentDate = (incident: ICrimeIncident) => {
|
const formatIncidentDate = (incident: ICrimeIncident) => {
|
||||||
try {
|
try {
|
||||||
if (incident.timestamp) {
|
if (incident.timestamp) {
|
||||||
return format(new Date(incident.timestamp), 'PPP p');
|
return format(new Date(incident.timestamp), "PPP p")
|
||||||
}
|
}
|
||||||
return 'N/A';
|
return "N/A"
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return 'Invalid date';
|
return "Invalid date"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -341,18 +364,22 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
<Button
|
<Button
|
||||||
variant={showSearch ? "default" : "ghost"}
|
variant={showSearch ? "default" : "ghost"}
|
||||||
size="medium"
|
size="medium"
|
||||||
className={`h-8 w-8 rounded-md ${showSearch
|
className={`h-8 w-8 rounded-md ${isSearchDisabled
|
||||||
|
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
|
||||||
|
: showSearch
|
||||||
? "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={toggleSearch}
|
onClick={toggleSearch}
|
||||||
|
disabled={isSearchDisabled}
|
||||||
|
aria-disabled={isSearchDisabled}
|
||||||
>
|
>
|
||||||
<Search size={20} />
|
<Search size={20} />
|
||||||
<span className="sr-only">Search Incidents</span>
|
<span className="sr-only">Search Incidents</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<p>Search Incidents</p>
|
<p>{isSearchDisabled ? "Not available for CBU data" : "Search Incidents"}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
@ -401,20 +428,23 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
activeActionId={selectedSearchType}
|
activeActionId={selectedSearchType}
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
placeholder={selectedSearchType ?
|
placeholder={
|
||||||
ACTIONS.find(a => a.id === selectedSearchType)?.placeholder :
|
selectedSearchType
|
||||||
"Select a search type..."}
|
? ACTIONS.find((a) => a.id === selectedSearchType)?.placeholder
|
||||||
inputClassName={!isInputValid ?
|
: "Select a search type..."
|
||||||
"border-destructive focus-visible:ring-destructive bg-destructive/50" : ""}
|
}
|
||||||
|
inputClassName={
|
||||||
|
!isInputValid ? "border-destructive focus-visible:ring-destructive bg-destructive/50" : ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isInputValid && selectedSearchType && (
|
{!isInputValid && selectedSearchType && (
|
||||||
<div className="mt-1 text-xs text-destructive">
|
<div className="mt-1 text-xs text-destructive">
|
||||||
Invalid format. {ACTIONS.find(a => a.id === selectedSearchType)?.description}
|
Invalid format. {ACTIONS.find((a) => a.id === selectedSearchType)?.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(suggestions.length > 0 && selectedSearchType) && (
|
{suggestions.length > 0 && selectedSearchType && (
|
||||||
<div className="mt-2 max-h-[300px] overflow-y-auto border border-border rounded-md bg-background/80 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
|
<div className="mt-2 max-h-[300px] overflow-y-auto border border-border rounded-md bg-background/80 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
|
||||||
<div className="sticky top-0 bg-muted/70 backdrop-blur-sm px-3 py-2 border-b border-border">
|
<div className="sticky top-0 bg-muted/70 backdrop-blur-sm px-3 py-2 border-b border-border">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
@ -431,17 +461,18 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
>
|
>
|
||||||
<span className="font-medium">{incident.id}</span>
|
<span className="font-medium">{incident.id}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectedSearchType === 'incident_id' ? (
|
{selectedSearchType === "incident_id" ? (
|
||||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||||
{incident.description}
|
{incident.description}
|
||||||
</span>
|
</span>
|
||||||
) : selectedSearchType === 'coordinates' ? (
|
) : selectedSearchType === "coordinates" ? (
|
||||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||||
{incident.locations.latitude}, {incident.locations.longitude} - {incident.description}
|
{incident.locations.latitude}, {incident.locations.longitude} -{" "}
|
||||||
|
{incident.description}
|
||||||
</span>
|
</span>
|
||||||
) : selectedSearchType === 'locations.address' ? (
|
) : selectedSearchType === "locations.address" ? (
|
||||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||||
{incident.locations.address || 'N/A'}
|
{incident.locations.address || "N/A"}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||||
|
@ -453,8 +484,8 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 rounded-full hover:bg-muted"
|
className="h-6 w-6 p-0 rounded-full hover:bg-muted"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
handleSuggestionSelect(incident);
|
handleSuggestionSelect(incident)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
@ -466,7 +497,8 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedSearchType && searchValue.length > (ACTIONS.find(a => a.id === selectedSearchType)?.prefix?.length || 0) &&
|
{selectedSearchType &&
|
||||||
|
searchValue.length > (ACTIONS.find((a) => a.id === selectedSearchType)?.prefix?.length || 0) &&
|
||||||
suggestions.length === 0 && (
|
suggestions.length === 0 && (
|
||||||
<div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center">
|
<div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center">
|
||||||
<p className="text-sm text-muted-foreground">No matching incidents found</p>
|
<p className="text-sm text-muted-foreground">No matching incidents found</p>
|
||||||
|
@ -477,15 +509,11 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
<p className="flex items-center text-sm text-muted-foreground">
|
<p className="flex items-center text-sm text-muted-foreground">
|
||||||
{selectedSearchType ? (
|
{selectedSearchType ? (
|
||||||
<>
|
<>
|
||||||
<span className="mr-1">{ACTIONS.find(a => a.id === selectedSearchType)?.icon}</span>
|
<span className="mr-1">{ACTIONS.find((a) => a.id === selectedSearchType)?.icon}</span>
|
||||||
<span>
|
<span>{ACTIONS.find((a) => a.id === selectedSearchType)?.description}</span>
|
||||||
{ACTIONS.find(a => a.id === selectedSearchType)?.description}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>Select a search type and enter your search criteria</span>
|
||||||
Select a search type and enter your search criteria
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -506,9 +534,7 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
{selectedSuggestion.timestamp && (
|
{selectedSuggestion.timestamp && (
|
||||||
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
|
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
|
||||||
<Calendar className="h-4 w-4 mt-1 text-muted-foreground" />
|
<Calendar className="h-4 w-4 mt-1 text-muted-foreground" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">{formatIncidentDate(selectedSuggestion)}</p>
|
||||||
{formatIncidentDate(selectedSuggestion)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -522,20 +548,16 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
<div className="bg-muted/50 rounded p-2">
|
<div className="bg-muted/50 rounded p-2">
|
||||||
<p className="text-xs text-muted-foreground font-medium">Category</p>
|
<p className="text-xs text-muted-foreground font-medium">Category</p>
|
||||||
<p className="text-sm">{selectedSuggestion.crime_categories?.name || 'N/A'}</p>
|
<p className="text-sm">{selectedSuggestion.crime_categories?.name || "N/A"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded p-2">
|
<div className="bg-muted/50 rounded p-2">
|
||||||
<p className="text-xs text-muted-foreground font-medium">Status</p>
|
<p className="text-xs text-muted-foreground font-medium">Status</p>
|
||||||
<p className="text-sm">{selectedSuggestion.status || 'N/A'}</p>
|
<p className="text-sm">{selectedSuggestion.status || "N/A"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-3 border-t border-border mt-3">
|
<div className="flex justify-between items-center pt-3 border-t border-border mt-3">
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={handleCloseInfoBox}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCloseInfoBox}
|
|
||||||
>
|
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { useRef, useState } from "react"
|
||||||
import CrimeTooltips from "./crime-tooltips"
|
import CrimeTooltips from "./crime-tooltips"
|
||||||
import AdditionalTooltips from "./additional-tooltips"
|
import AdditionalTooltips from "./additional-tooltips"
|
||||||
import SearchTooltip from "./search-control"
|
import SearchTooltip from "./search-control"
|
||||||
import { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
// Define the possible control IDs for the crime map
|
// Define the possible control IDs for the crime map
|
||||||
export type ITooltips =
|
export type ITooltips =
|
||||||
|
@ -24,19 +24,22 @@ export type ITooltips =
|
||||||
| "alerts"
|
| "alerts"
|
||||||
| "layers"
|
| "layers"
|
||||||
| "evidence"
|
| "evidence"
|
||||||
| "arrests";
|
| "arrests"
|
||||||
|
|
||||||
// Map tools type definition
|
// Map tools type definition
|
||||||
export interface IMapTools {
|
export interface IMapTools {
|
||||||
id: ITooltips;
|
id: ITooltips
|
||||||
label: string;
|
label: string
|
||||||
icon: ReactNode;
|
icon: ReactNode
|
||||||
description?: string;
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
onControlChange?: (controlId: ITooltips) => void
|
onControlChange?: (controlId: ITooltips) => void
|
||||||
activeControl?: string
|
activeControl?: string
|
||||||
|
selectedSourceType: string
|
||||||
|
setSelectedSourceType: (sourceType: string) => void
|
||||||
|
availableSourceTypes: string[] // This must be string[] to match with API response
|
||||||
selectedYear: number
|
selectedYear: number
|
||||||
setSelectedYear: (year: number) => void
|
setSelectedYear: (year: number) => void
|
||||||
selectedMonth: number | "all"
|
selectedMonth: number | "all"
|
||||||
|
@ -51,15 +54,18 @@ interface TooltipProps {
|
||||||
export default function Tooltips({
|
export default function Tooltips({
|
||||||
onControlChange,
|
onControlChange,
|
||||||
activeControl,
|
activeControl,
|
||||||
|
selectedSourceType,
|
||||||
|
setSelectedSourceType,
|
||||||
|
availableSourceTypes = [],
|
||||||
selectedYear,
|
selectedYear,
|
||||||
setSelectedYear,
|
setSelectedYear,
|
||||||
selectedMonth,
|
selectedMonth,
|
||||||
setSelectedMonth,
|
setSelectedMonth,
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
setSelectedCategory,
|
setSelectedCategory,
|
||||||
availableYears = [2022, 2023, 2024],
|
availableYears = [],
|
||||||
categories = [],
|
categories = [],
|
||||||
crimes = []
|
crimes = [],
|
||||||
}: TooltipProps) {
|
}: TooltipProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [isClient, setIsClient] = useState(false)
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
@ -68,12 +74,19 @@ export default function Tooltips({
|
||||||
<div ref={containerRef} className="flex flex-col items-center gap-2">
|
<div ref={containerRef} className="flex flex-col items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Crime Tooltips Component */}
|
{/* Crime Tooltips Component */}
|
||||||
<CrimeTooltips activeControl={activeControl} onControlChange={onControlChange} />
|
<CrimeTooltips
|
||||||
|
activeControl={activeControl}
|
||||||
|
onControlChange={onControlChange}
|
||||||
|
sourceType={selectedSourceType}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Additional Tooltips Component */}
|
{/* Additional Tooltips Component */}
|
||||||
<AdditionalTooltips
|
<AdditionalTooltips
|
||||||
activeControl={activeControl}
|
activeControl={activeControl}
|
||||||
onControlChange={onControlChange}
|
onControlChange={onControlChange}
|
||||||
|
selectedSourceType={selectedSourceType}
|
||||||
|
setSelectedSourceType={setSelectedSourceType}
|
||||||
|
availableSourceTypes={availableSourceTypes}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
setSelectedYear={setSelectedYear}
|
setSelectedYear={setSelectedYear}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
|
@ -88,7 +101,8 @@ export default function Tooltips({
|
||||||
<SearchTooltip
|
<SearchTooltip
|
||||||
activeControl={activeControl}
|
activeControl={activeControl}
|
||||||
onControlChange={onControlChange}
|
onControlChange={onControlChange}
|
||||||
crimes={crimes} // Pass crimes data here
|
crimes={crimes}
|
||||||
|
sourceType={selectedSourceType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { Overlay } from "./overlay"
|
||||||
import MapLegend from "./legends/map-legend"
|
import MapLegend from "./legends/map-legend"
|
||||||
import UnitsLegend from "./legends/units-legend"
|
import UnitsLegend from "./legends/units-legend"
|
||||||
import TimelineLegend from "./legends/timeline-legend"
|
import TimelineLegend from "./legends/timeline-legend"
|
||||||
import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes, useGetCrimeTypes, useGetRecentIncidents } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
||||||
import MapSelectors from "./controls/map-selector"
|
import MapSelectors from "./controls/map-selector"
|
||||||
|
|
||||||
import { cn } from "@/app/_lib/utils"
|
import { cn } from "@/app/_lib/utils"
|
||||||
|
@ -29,10 +29,11 @@ export default function CrimeMap() {
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
||||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||||
const [showLegend, setShowLegend] = useState<boolean>(true)
|
const [showLegend, setShowLegend] = useState<boolean>(true)
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
|
const [activeControl, setActiveControl] = useState<ITooltips>("incidents")
|
||||||
|
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu")
|
||||||
const [selectedYear, setSelectedYear] = useState<number>(2024)
|
const [selectedYear, setSelectedYear] = useState<number>(2024)
|
||||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
||||||
const [activeControl, setActiveControl] = useState<ITooltips>("incidents")
|
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
|
||||||
const [yearProgress, setYearProgress] = useState(0)
|
const [yearProgress, setYearProgress] = useState(0)
|
||||||
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
|
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
|
||||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||||
|
@ -48,6 +49,8 @@ export default function CrimeMap() {
|
||||||
|
|
||||||
const { isFullscreen } = useFullscreen(mapContainerRef)
|
const { isFullscreen } = useFullscreen(mapContainerRef)
|
||||||
|
|
||||||
|
const { data: availableSourceTypes, isLoading: isTypeLoading } = useGetCrimeTypes()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: availableYears,
|
data: availableYears,
|
||||||
isLoading: isYearsLoading,
|
isLoading: isYearsLoading,
|
||||||
|
@ -68,6 +71,8 @@ export default function CrimeMap() {
|
||||||
|
|
||||||
const { data: fetchedUnits, isLoading } = useGetUnitsQuery()
|
const { data: fetchedUnits, isLoading } = useGetUnitsQuery()
|
||||||
|
|
||||||
|
const { data: recentIncidents } = useGetRecentIncidents()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeControl === "heatmap" || activeControl === "timeline") {
|
if (activeControl === "heatmap" || activeControl === "timeline") {
|
||||||
setUseAllYears(true);
|
setUseAllYears(true);
|
||||||
|
@ -78,20 +83,25 @@ export default function CrimeMap() {
|
||||||
}
|
}
|
||||||
}, [activeControl]);
|
}, [activeControl]);
|
||||||
|
|
||||||
const filteredByYearAndMonth = useMemo(() => {
|
const crimesBySourceType = useMemo(() => {
|
||||||
if (!crimes) return [];
|
if (!crimes) return [];
|
||||||
|
return crimes.filter(crime => crime.source_type === selectedSourceType);
|
||||||
|
}, [crimes, selectedSourceType]);
|
||||||
|
|
||||||
|
const filteredByYearAndMonth = useMemo(() => {
|
||||||
|
if (!crimesBySourceType) return [];
|
||||||
|
|
||||||
if (useAllYears) {
|
if (useAllYears) {
|
||||||
if (useAllMonths) {
|
if (useAllMonths) {
|
||||||
return crimes;
|
return crimesBySourceType;
|
||||||
} else {
|
} else {
|
||||||
return crimes.filter((crime) => {
|
return crimesBySourceType.filter((crime) => {
|
||||||
return selectedMonth === "all" ? true : crime.month === selectedMonth;
|
return selectedMonth === "all" ? true : crime.month === selectedMonth;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return crimes.filter((crime) => {
|
return crimesBySourceType.filter((crime) => {
|
||||||
const yearMatch = crime.year === selectedYear;
|
const yearMatch = crime.year === selectedYear;
|
||||||
|
|
||||||
if (selectedMonth === "all" || useAllMonths) {
|
if (selectedMonth === "all" || useAllMonths) {
|
||||||
|
@ -100,7 +110,7 @@ export default function CrimeMap() {
|
||||||
return yearMatch && crime.month === selectedMonth;
|
return yearMatch && crime.month === selectedMonth;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [crimes, selectedYear, selectedMonth, useAllYears, useAllMonths]);
|
}, [crimesBySourceType, selectedYear, selectedMonth, useAllYears, useAllMonths]);
|
||||||
|
|
||||||
const filteredCrimes = useMemo(() => {
|
const filteredCrimes = useMemo(() => {
|
||||||
if (!filteredByYearAndMonth) return []
|
if (!filteredByYearAndMonth) return []
|
||||||
|
@ -119,6 +129,32 @@ export default function CrimeMap() {
|
||||||
})
|
})
|
||||||
}, [filteredByYearAndMonth, selectedCategory])
|
}, [filteredByYearAndMonth, selectedCategory])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedSourceType === "cbu") {
|
||||||
|
if (activeControl !== "clusters" && activeControl !== "reports" &&
|
||||||
|
activeControl !== "layers" && activeControl !== "search" &&
|
||||||
|
activeControl !== "alerts") {
|
||||||
|
setActiveControl("clusters");
|
||||||
|
setShowClusters(true);
|
||||||
|
setShowUnclustered(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedSourceType, activeControl]);
|
||||||
|
|
||||||
|
const handleSourceTypeChange = useCallback((sourceType: string) => {
|
||||||
|
setSelectedSourceType(sourceType);
|
||||||
|
|
||||||
|
if (sourceType === "cbu") {
|
||||||
|
setActiveControl("clusters");
|
||||||
|
setShowClusters(true);
|
||||||
|
setShowUnclustered(false);
|
||||||
|
} else {
|
||||||
|
setActiveControl("incidents");
|
||||||
|
setShowUnclustered(true);
|
||||||
|
setShowClusters(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
|
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
|
||||||
setSelectedYear(year)
|
setSelectedYear(year)
|
||||||
setSelectedMonth(month)
|
setSelectedMonth(month)
|
||||||
|
@ -155,6 +191,11 @@ export default function CrimeMap() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleControlChange = (controlId: ITooltips) => {
|
const handleControlChange = (controlId: ITooltips) => {
|
||||||
|
if (selectedSourceType === "cbu" &&
|
||||||
|
!["clusters", "reports", "layers", "search", "alerts"].includes(controlId as string)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setActiveControl(controlId);
|
setActiveControl(controlId);
|
||||||
|
|
||||||
if (controlId === "clusters") {
|
if (controlId === "clusters") {
|
||||||
|
@ -187,7 +228,6 @@ export default function CrimeMap() {
|
||||||
setUseAllMonths(false);
|
setUseAllMonths(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable EWS in all modes for demo purposes
|
|
||||||
setShowEWS(true);
|
setShowEWS(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,7 +262,7 @@ export default function CrimeMap() {
|
||||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mapbox-container overlay-bg vertical-reveal relative h-[600px]" ref={mapContainerRef}>
|
<div className="mapbox-container overlay-bg relative h-[600px]" ref={mapContainerRef}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"transition-all duration-300 ease-in-out",
|
"transition-all duration-300 ease-in-out",
|
||||||
!sidebarCollapsed && isFullscreen && "ml-[400px]"
|
!sidebarCollapsed && isFullscreen && "ml-[400px]"
|
||||||
|
@ -238,6 +278,8 @@ export default function CrimeMap() {
|
||||||
activeControl={activeControl}
|
activeControl={activeControl}
|
||||||
useAllData={useAllYears}
|
useAllData={useAllYears}
|
||||||
showEWS={showEWS}
|
showEWS={showEWS}
|
||||||
|
recentIncidents={recentIncidents || []}
|
||||||
|
sourceType={selectedSourceType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isFullscreen && (
|
{isFullscreen && (
|
||||||
|
@ -246,6 +288,9 @@ export default function CrimeMap() {
|
||||||
<Tooltips
|
<Tooltips
|
||||||
activeControl={activeControl}
|
activeControl={activeControl}
|
||||||
onControlChange={handleControlChange}
|
onControlChange={handleControlChange}
|
||||||
|
selectedSourceType={selectedSourceType}
|
||||||
|
setSelectedSourceType={handleSourceTypeChange}
|
||||||
|
availableSourceTypes={availableSourceTypes || []}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
setSelectedYear={setSelectedYear}
|
setSelectedYear={setSelectedYear}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
|
@ -264,6 +309,7 @@ export default function CrimeMap() {
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
|
sourceType={selectedSourceType} // Pass the sourceType
|
||||||
/>
|
/>
|
||||||
{isFullscreen && (
|
{isFullscreen && (
|
||||||
<div className="absolute bottom-20 right-0 z-20 p-2">
|
<div className="absolute bottom-20 right-0 z-20 p-2">
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useCallback } from "react"
|
import { useEffect, useCallback } from "react"
|
||||||
|
|
||||||
import type mapboxgl from "mapbox-gl"
|
import mapboxgl from "mapbox-gl"
|
||||||
import type { GeoJSON } from "geojson"
|
import type { GeoJSON } from "geojson"
|
||||||
import type { IClusterLayerProps } from "@/app/_utils/types/map"
|
import type { IClusterLayerProps } from "@/app/_utils/types/map"
|
||||||
import { extractCrimeIncidents } from "@/app/_utils/map"
|
import { extractCrimeIncidents } from "@/app/_utils/map"
|
||||||
|
@ -10,6 +10,7 @@ import { extractCrimeIncidents } from "@/app/_utils/map"
|
||||||
interface ExtendedClusterLayerProps extends IClusterLayerProps {
|
interface ExtendedClusterLayerProps extends IClusterLayerProps {
|
||||||
clusteringEnabled?: boolean
|
clusteringEnabled?: boolean
|
||||||
showClusters?: boolean
|
showClusters?: boolean
|
||||||
|
sourceType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClusterLayer({
|
export default function ClusterLayer({
|
||||||
|
@ -20,6 +21,7 @@ export default function ClusterLayer({
|
||||||
focusedDistrictId,
|
focusedDistrictId,
|
||||||
clusteringEnabled = false,
|
clusteringEnabled = false,
|
||||||
showClusters = false,
|
showClusters = false,
|
||||||
|
sourceType = "cbt",
|
||||||
}: ExtendedClusterLayerProps) {
|
}: ExtendedClusterLayerProps) {
|
||||||
const handleClusterClick = useCallback(
|
const handleClusterClick = useCallback(
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
|
@ -35,7 +37,6 @@ export default function ClusterLayer({
|
||||||
const clusterId: number = features[0].properties?.cluster_id as number
|
const clusterId: number = features[0].properties?.cluster_id as number
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the expanded zoom level for this cluster
|
|
||||||
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
|
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
|
||||||
clusterId,
|
clusterId,
|
||||||
(err, zoom) => {
|
(err, zoom) => {
|
||||||
|
@ -46,7 +47,6 @@ export default function ClusterLayer({
|
||||||
|
|
||||||
const coordinates = (features[0].geometry as any).coordinates
|
const coordinates = (features[0].geometry as any).coordinates
|
||||||
|
|
||||||
// Explicitly fly to the cluster location
|
|
||||||
map.flyTo({
|
map.flyTo({
|
||||||
center: coordinates,
|
center: coordinates,
|
||||||
zoom: zoom ?? 12,
|
zoom: zoom ?? 12,
|
||||||
|
@ -80,13 +80,38 @@ export default function ClusterLayer({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getSource("crime-incidents")) {
|
if (!map.getSource("crime-incidents")) {
|
||||||
const allIncidents = extractCrimeIncidents(crimes, filterCategory)
|
let features: GeoJSON.Feature[] = []
|
||||||
|
|
||||||
|
if (sourceType === "cbu") {
|
||||||
|
features = crimes.map(crime => ({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
district_id: crime.district_id,
|
||||||
|
district_name: crime.districts ? crime.districts.name : "Unknown",
|
||||||
|
crime_count: crime.number_of_crime || 0,
|
||||||
|
level: crime.level,
|
||||||
|
category: filterCategory !== "all" ? filterCategory : "All",
|
||||||
|
year: crime.year,
|
||||||
|
month: crime.month,
|
||||||
|
isCBU: true,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [
|
||||||
|
crime.districts?.geographics?.[0]?.longitude || 0,
|
||||||
|
crime.districts?.geographics?.[0]?.latitude || 0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})) as GeoJSON.Feature[]
|
||||||
|
} else {
|
||||||
|
features = extractCrimeIncidents(crimes, filterCategory).filter(Boolean) as GeoJSON.Feature[]
|
||||||
|
}
|
||||||
|
|
||||||
map.addSource("crime-incidents", {
|
map.addSource("crime-incidents", {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
data: {
|
data: {
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: allIncidents as GeoJSON.Feature[],
|
features: features,
|
||||||
},
|
},
|
||||||
cluster: clusteringEnabled,
|
cluster: clusteringEnabled,
|
||||||
clusterMaxZoom: 14,
|
clusterMaxZoom: 14,
|
||||||
|
@ -131,6 +156,103 @@ export default function ClusterLayer({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceType === "cbu" && !map.getLayer("crime-points")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "crime-points",
|
||||||
|
type: "circle",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["!", ["has", "point_count"]],
|
||||||
|
paint: {
|
||||||
|
"circle-radius": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
8,
|
||||||
|
["interpolate", ["linear"], ["get", "crime_count"], 0, 5, 100, 20],
|
||||||
|
12,
|
||||||
|
["interpolate", ["linear"], ["get", "crime_count"], 0, 8, 100, 30],
|
||||||
|
],
|
||||||
|
"circle-color": [
|
||||||
|
"match",
|
||||||
|
["get", "level"],
|
||||||
|
"low",
|
||||||
|
"#47B39C",
|
||||||
|
"medium",
|
||||||
|
"#FFC154",
|
||||||
|
"high",
|
||||||
|
"#EC6B56",
|
||||||
|
"#888888",
|
||||||
|
],
|
||||||
|
"circle-opacity": 0.7,
|
||||||
|
"circle-stroke-width": 1,
|
||||||
|
"circle-stroke-color": "#ffffff",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "crime-count-labels",
|
||||||
|
type: "symbol",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["!", ["has", "point_count"]],
|
||||||
|
layout: {
|
||||||
|
"text-field": "{crime_count}",
|
||||||
|
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
|
||||||
|
"text-size": 12,
|
||||||
|
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "#ffffff",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on("mouseenter", "crime-points", () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer"
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on("mouseleave", "crime-points", () => {
|
||||||
|
map.getCanvas().style.cursor = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCrimePointClick = (e: any) => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
e.originalEvent.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ["crime-points"] })
|
||||||
|
|
||||||
|
if (features.length > 0) {
|
||||||
|
const feature = features[0]
|
||||||
|
const props = feature.properties
|
||||||
|
const coordinates = (feature.geometry as any).coordinates.slice()
|
||||||
|
|
||||||
|
if (props) {
|
||||||
|
if (props) {
|
||||||
|
const popupHTML = `
|
||||||
|
<div class="p-3">
|
||||||
|
<h3 class="font-bold">${props.district_name}</h3>
|
||||||
|
<div class="mt-2">
|
||||||
|
<p>Total Crimes: <b>${props.crime_count}</b></p>
|
||||||
|
<p>Crime Level: <b>${props.level}</b></p>
|
||||||
|
<p>Year: ${props.year} - Month: ${props.month}</p>
|
||||||
|
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.off("click", "crime-points", handleCrimePointClick)
|
||||||
|
map.on("click", "crime-points", handleCrimePointClick)
|
||||||
|
}
|
||||||
|
|
||||||
map.on("mouseenter", "clusters", () => {
|
map.on("mouseenter", "clusters", () => {
|
||||||
map.getCanvas().style.cursor = "pointer"
|
map.getCanvas().style.cursor = "pointer"
|
||||||
})
|
})
|
||||||
|
@ -139,36 +261,66 @@ export default function ClusterLayer({
|
||||||
map.getCanvas().style.cursor = ""
|
map.getCanvas().style.cursor = ""
|
||||||
})
|
})
|
||||||
|
|
||||||
// Remove and re-add click handler to avoid duplicates
|
|
||||||
map.off("click", "clusters", handleClusterClick)
|
map.off("click", "clusters", handleClusterClick)
|
||||||
map.on("click", "clusters", handleClusterClick)
|
map.on("click", "clusters", handleClusterClick)
|
||||||
} else {
|
} else {
|
||||||
// Update source clustering option
|
|
||||||
try {
|
try {
|
||||||
// We need to recreate the source if we're changing the clustering option
|
|
||||||
const currentSource = map.getSource("crime-incidents") as mapboxgl.GeoJSONSource
|
const currentSource = map.getSource("crime-incidents") as mapboxgl.GeoJSONSource
|
||||||
const data = (currentSource as any)._data // Get current data
|
const data = (currentSource as any)._data
|
||||||
|
|
||||||
// If clustering state has changed, recreate the source
|
|
||||||
const existingClusterState = (currentSource as any).options?.cluster
|
const existingClusterState = (currentSource as any).options?.cluster
|
||||||
if (existingClusterState !== clusteringEnabled) {
|
const sourceTypeChanged =
|
||||||
// Remove existing layers that use this source
|
data.features.length > 0 &&
|
||||||
|
((sourceType === "cbu" && !data.features[0].properties.isCBU) ||
|
||||||
|
(sourceType !== "cbu" && data.features[0].properties.isCBU))
|
||||||
|
|
||||||
|
if (existingClusterState !== clusteringEnabled || sourceTypeChanged) {
|
||||||
if (map.getLayer("clusters")) map.removeLayer("clusters")
|
if (map.getLayer("clusters")) map.removeLayer("clusters")
|
||||||
if (map.getLayer("cluster-count")) map.removeLayer("cluster-count")
|
if (map.getLayer("cluster-count")) map.removeLayer("cluster-count")
|
||||||
if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point")
|
if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point")
|
||||||
|
if (map.getLayer("crime-points")) map.removeLayer("crime-points")
|
||||||
|
if (map.getLayer("crime-count-labels")) map.removeLayer("crime-count-labels")
|
||||||
|
|
||||||
// Remove and recreate source with new clustering setting
|
|
||||||
map.removeSource("crime-incidents")
|
map.removeSource("crime-incidents")
|
||||||
|
|
||||||
|
let features: GeoJSON.Feature[] = []
|
||||||
|
|
||||||
|
if (sourceType === "cbu") {
|
||||||
|
features = crimes.map(crime => ({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
district_id: crime.district_id,
|
||||||
|
district_name: crime.districts ? crime.districts.name : "Unknown",
|
||||||
|
crime_count: crime.number_of_crime || 0,
|
||||||
|
level: crime.level,
|
||||||
|
category: filterCategory !== "all" ? filterCategory : "All",
|
||||||
|
year: crime.year,
|
||||||
|
month: crime.month,
|
||||||
|
isCBU: true,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [
|
||||||
|
crime.districts?.geographics?.[0]?.longitude || 0,
|
||||||
|
crime.districts?.geographics?.[0]?.latitude || 0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})) as GeoJSON.Feature[]
|
||||||
|
} else {
|
||||||
|
features = extractCrimeIncidents(crimes, filterCategory).filter(Boolean) as GeoJSON.Feature[]
|
||||||
|
}
|
||||||
|
|
||||||
map.addSource("crime-incidents", {
|
map.addSource("crime-incidents", {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
data: data,
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: features,
|
||||||
|
},
|
||||||
cluster: clusteringEnabled,
|
cluster: clusteringEnabled,
|
||||||
clusterMaxZoom: 14,
|
clusterMaxZoom: 14,
|
||||||
clusterRadius: 50,
|
clusterRadius: 50,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-add the layers
|
|
||||||
if (!map.getLayer("clusters")) {
|
if (!map.getLayer("clusters")) {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
|
@ -206,12 +358,108 @@ export default function ClusterLayer({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sourceType === "cbu") {
|
||||||
|
if (!map.getLayer("crime-points")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "crime-points",
|
||||||
|
type: "circle",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["!", ["has", "point_count"]],
|
||||||
|
paint: {
|
||||||
|
"circle-radius": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
8,
|
||||||
|
["interpolate", ["linear"], ["get", "crime_count"], 0, 5, 100, 20],
|
||||||
|
12,
|
||||||
|
["interpolate", ["linear"], ["get", "crime_count"], 0, 8, 100, 30],
|
||||||
|
],
|
||||||
|
"circle-color": [
|
||||||
|
"match",
|
||||||
|
["get", "level"],
|
||||||
|
"low",
|
||||||
|
"#47B39C",
|
||||||
|
"medium",
|
||||||
|
"#FFC154",
|
||||||
|
"high",
|
||||||
|
"#EC6B56",
|
||||||
|
"#888888",
|
||||||
|
],
|
||||||
|
"circle-opacity": 0.7,
|
||||||
|
"circle-stroke-width": 1,
|
||||||
|
"circle-stroke-color": "#ffffff",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "crime-count-labels",
|
||||||
|
type: "symbol",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["!", ["has", "point_count"]],
|
||||||
|
layout: {
|
||||||
|
"text-field": "{crime_count}",
|
||||||
|
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
|
||||||
|
"text-size": 12,
|
||||||
|
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "#ffffff",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on("mouseenter", "crime-points", () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer"
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on("mouseleave", "crime-points", () => {
|
||||||
|
map.getCanvas().style.cursor = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCrimePointClick = (e: any) => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
e.originalEvent.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ["crime-points"] })
|
||||||
|
|
||||||
|
if (features.length > 0) {
|
||||||
|
const feature = features[0]
|
||||||
|
const props = feature.properties
|
||||||
|
const coordinates = (feature.geometry as any).coordinates.slice()
|
||||||
|
|
||||||
|
if (props) {
|
||||||
|
const popupHTML = `
|
||||||
|
<div class="p-3">
|
||||||
|
<h3 class="font-bold">${props.district_name}</h3>
|
||||||
|
<div class="mt-2">
|
||||||
|
<p>Total Crimes: <b>${props.crime_count}</b></p>
|
||||||
|
<p>Crime Level: <b>${props.level}</b></p>
|
||||||
|
<p>Year: ${props.year} - Month: ${props.month}</p>
|
||||||
|
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map.off("click", "crime-points", handleCrimePointClick)
|
||||||
|
map.on("click", "crime-points", handleCrimePointClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating cluster source:", error)
|
console.error("Error updating cluster source:", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update visibility based on focused district and showClusters flag
|
|
||||||
if (map.getLayer("clusters")) {
|
if (map.getLayer("clusters")) {
|
||||||
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
|
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
|
||||||
}
|
}
|
||||||
|
@ -224,7 +472,19 @@ export default function ClusterLayer({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the cluster click handler
|
if (sourceType === "cbu" && map.getLayer("crime-points")) {
|
||||||
|
map.setLayoutProperty(
|
||||||
|
"crime-points",
|
||||||
|
"visibility",
|
||||||
|
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||||
|
)
|
||||||
|
map.setLayoutProperty(
|
||||||
|
"crime-count-labels",
|
||||||
|
"visibility",
|
||||||
|
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
map.off("click", "clusters", handleClusterClick)
|
map.off("click", "clusters", handleClusterClick)
|
||||||
map.on("click", "clusters", handleClusterClick)
|
map.on("click", "clusters", handleClusterClick)
|
||||||
}
|
}
|
||||||
|
@ -242,36 +502,150 @@ export default function ClusterLayer({
|
||||||
return () => {
|
return () => {
|
||||||
if (map) {
|
if (map) {
|
||||||
map.off("click", "clusters", handleClusterClick)
|
map.off("click", "clusters", handleClusterClick)
|
||||||
|
if (sourceType === "cbu" && map.getLayer("crime-points")) {
|
||||||
|
// Define properly typed event handlers
|
||||||
|
const crimePointsMouseEnter = function () {
|
||||||
|
if (map && map.getCanvas()) {
|
||||||
|
map.getCanvas().style.cursor = "pointer";
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [map, visible, crimes, filterCategory, focusedDistrictId, handleClusterClick, clusteringEnabled, showClusters])
|
|
||||||
|
const crimePointsMouseLeave = function () {
|
||||||
|
if (map && map.getCanvas()) {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const crimePointsClick = function (e: mapboxgl.MapMouseEvent) {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
e.originalEvent.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ["crime-points"] });
|
||||||
|
|
||||||
|
if (features.length > 0) {
|
||||||
|
const feature = features[0];
|
||||||
|
const props = feature.properties;
|
||||||
|
const coordinates = (feature.geometry as any).coordinates.slice();
|
||||||
|
|
||||||
|
if (props) {
|
||||||
|
const popupHTML = `
|
||||||
|
<div class="p-3">
|
||||||
|
<h3 class="font-bold">${props.district_name}</h3>
|
||||||
|
<div class="mt-2">
|
||||||
|
<p>Total Crimes: <b>${props.crime_count}</b></p>
|
||||||
|
<p>Crime Level: <b>${props.level}</b></p>
|
||||||
|
<p>Year: ${props.year} - Month: ${props.month}</p>
|
||||||
|
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
new mapboxgl.Popup()
|
||||||
|
.setLngLat(coordinates)
|
||||||
|
.setHTML(popupHTML)
|
||||||
|
.addTo(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove event listeners with properly typed handlers
|
||||||
|
map.off("mouseenter", "crime-points", crimePointsMouseEnter);
|
||||||
|
map.off("mouseleave", "crime-points", crimePointsMouseLeave);
|
||||||
|
map.off("click", "crime-points", crimePointsClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
map,
|
||||||
|
visible,
|
||||||
|
crimes,
|
||||||
|
filterCategory,
|
||||||
|
focusedDistrictId,
|
||||||
|
handleClusterClick,
|
||||||
|
clusteringEnabled,
|
||||||
|
showClusters,
|
||||||
|
sourceType,
|
||||||
|
])
|
||||||
|
|
||||||
// Update crime incidents data when filters change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !map.getSource("crime-incidents")) return
|
if (!map || !map.getSource("crime-incidents")) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allIncidents = extractCrimeIncidents(crimes, filterCategory)
|
let features: GeoJSON.Feature[]
|
||||||
|
|
||||||
|
if (sourceType === "cbu") {
|
||||||
|
features = crimes.map(crime => ({
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
district_id: crime.district_id,
|
||||||
|
district_name: crime.districts ? crime.districts.name : "Unknown",
|
||||||
|
crime_count: crime.number_of_crime || 0,
|
||||||
|
level: crime.level,
|
||||||
|
category: filterCategory !== "all" ? filterCategory : "All",
|
||||||
|
year: crime.year,
|
||||||
|
month: crime.month,
|
||||||
|
isCBU: true,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [
|
||||||
|
crime.districts?.geographics?.[0]?.longitude || 0,
|
||||||
|
crime.districts?.geographics?.[0]?.latitude || 0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})) as GeoJSON.Feature[]
|
||||||
|
} else {
|
||||||
|
features = extractCrimeIncidents(crimes, filterCategory).filter(Boolean) as GeoJSON.Feature[]
|
||||||
|
}
|
||||||
|
|
||||||
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: allIncidents as GeoJSON.Feature[],
|
features: features,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating incident data:", error)
|
console.error("Error updating incident data:", error)
|
||||||
}
|
}
|
||||||
}, [map, crimes, filterCategory])
|
}, [map, crimes, filterCategory, sourceType])
|
||||||
|
|
||||||
// Update visibility when showClusters changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !map.getLayer("clusters") || !map.getLayer("cluster-count")) return
|
if (!map) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (map.getLayer("clusters")) {
|
||||||
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
|
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
|
||||||
map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
|
}
|
||||||
|
|
||||||
|
if (map.getLayer("cluster-count")) {
|
||||||
|
map.setLayoutProperty(
|
||||||
|
"cluster-count",
|
||||||
|
"visibility",
|
||||||
|
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceType === "cbu") {
|
||||||
|
if (map.getLayer("crime-points")) {
|
||||||
|
map.setLayoutProperty(
|
||||||
|
"crime-points",
|
||||||
|
"visibility",
|
||||||
|
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.getLayer("crime-count-labels")) {
|
||||||
|
map.setLayoutProperty(
|
||||||
|
"crime-count-labels",
|
||||||
|
"visibility",
|
||||||
|
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating cluster visibility:", error)
|
console.error("Error updating cluster visibility:", error)
|
||||||
}
|
}
|
||||||
}, [map, showClusters, focusedDistrictId])
|
}, [map, showClusters, focusedDistrictId, sourceType])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,6 +76,7 @@ interface LayersProps {
|
||||||
tilesetId?: string
|
tilesetId?: string
|
||||||
useAllData?: boolean
|
useAllData?: boolean
|
||||||
showEWS?: boolean
|
showEWS?: boolean
|
||||||
|
sourceType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layers({
|
export default function Layers({
|
||||||
|
@ -90,6 +91,7 @@ export default function Layers({
|
||||||
tilesetId = MAPBOX_TILESET_ID,
|
tilesetId = MAPBOX_TILESET_ID,
|
||||||
useAllData = false,
|
useAllData = false,
|
||||||
showEWS = true,
|
showEWS = true,
|
||||||
|
sourceType = "cbt",
|
||||||
}: LayersProps) {
|
}: LayersProps) {
|
||||||
const { current: map } = useMap()
|
const { current: map } = useMap()
|
||||||
|
|
||||||
|
@ -416,11 +418,11 @@ export default function Layers({
|
||||||
if (!visible) return null
|
if (!visible) return null
|
||||||
|
|
||||||
const crimesVisible = activeControl === "incidents"
|
const crimesVisible = activeControl === "incidents"
|
||||||
const showHeatmapLayer = activeControl === "heatmap"
|
const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu"
|
||||||
const showUnitsLayer = activeControl === "units"
|
const showUnitsLayer = activeControl === "units"
|
||||||
const showTimelineLayer = activeControl === "timeline"
|
const showTimelineLayer = activeControl === "timeline"
|
||||||
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"
|
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"
|
||||||
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline"
|
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline" && sourceType !== "cbu"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -484,6 +486,7 @@ export default function Layers({
|
||||||
focusedDistrictId={focusedDistrictId}
|
focusedDistrictId={focusedDistrictId}
|
||||||
clusteringEnabled={activeControl === "clusters"}
|
clusteringEnabled={activeControl === "clusters"}
|
||||||
showClusters={activeControl === "clusters"}
|
showClusters={activeControl === "clusters"}
|
||||||
|
sourceType={sourceType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UnclusteredPointLayer
|
<UnclusteredPointLayer
|
||||||
|
|
|
@ -1171,3 +1171,4 @@ export function generateId(
|
||||||
|
|
||||||
return result.trim();
|
return result.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
type Severity = "Low" | "Medium" | "High" | "Unknown";
|
||||||
|
|
||||||
|
export function getSeverity(crimeName: string): Severity {
|
||||||
|
const high = [
|
||||||
|
"Pembunuhan",
|
||||||
|
"Perkosaan",
|
||||||
|
"Penculikan",
|
||||||
|
"Pembakaran",
|
||||||
|
"Kebakaran / Meletus",
|
||||||
|
"Curas",
|
||||||
|
"Curanmor",
|
||||||
|
"Lahgun Senpi/Handak/Sajam",
|
||||||
|
"Trafficking In Person",
|
||||||
|
];
|
||||||
|
|
||||||
|
const medium = [
|
||||||
|
"Penganiayaan Berat",
|
||||||
|
"Penganiayaan Ringan",
|
||||||
|
"Perjudian",
|
||||||
|
"Sumpah Palsu",
|
||||||
|
"Pemalsuan Materai",
|
||||||
|
"Pemalsuan Surat",
|
||||||
|
"Perbuatan Tidak Menyenangkan",
|
||||||
|
"Premanisme",
|
||||||
|
"Pemerasan Dan Pengancaman",
|
||||||
|
"Penggelapan",
|
||||||
|
"Penipuan",
|
||||||
|
"Pengeroyokan",
|
||||||
|
"PKDRT",
|
||||||
|
"Money Loudering",
|
||||||
|
"Illegal Logging",
|
||||||
|
"Illegal Mining",
|
||||||
|
];
|
||||||
|
|
||||||
|
const low = [
|
||||||
|
"Member Suap",
|
||||||
|
"Menerima Suap",
|
||||||
|
"Penghinaan",
|
||||||
|
"Perzinahan",
|
||||||
|
"Terhadap Ketertiban Umum",
|
||||||
|
"Membahayakan Kam Umum",
|
||||||
|
"Kenakalan Remaja",
|
||||||
|
"Perlindungan Anak",
|
||||||
|
"Perlindungan TKI",
|
||||||
|
"Perlindungan Saksi – Korban",
|
||||||
|
"Pekerjakan Anak",
|
||||||
|
"Pengrusakan",
|
||||||
|
"ITE",
|
||||||
|
"Perlindungan Konsumen",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (high.includes(crimeName)) return "High";
|
||||||
|
if (medium.includes(crimeName)) return "Medium";
|
||||||
|
if (low.includes(crimeName)) return "Low";
|
||||||
|
return "Unknown";
|
||||||
|
}
|
|
@ -104,7 +104,7 @@ export interface IIncidentLogs {
|
||||||
source: string;
|
source: string;
|
||||||
description: string;
|
description: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
severity: 'high' | 'medium' | 'low';
|
severity: "Low" | "Medium" | "High" | "Unknown";
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
|
|
|
@ -1,33 +1,157 @@
|
||||||
export const districtCenters = [
|
export const districtCenters = [
|
||||||
{ kecamatan: "Sumbersari", lat: -8.170662, lng: 113.727582 },
|
{
|
||||||
{ kecamatan: "Wuluhan", lat: -8.365478, lng: 113.537137 },
|
kecamatan: "Ajung",
|
||||||
{ kecamatan: "Bangsalsari", lat: -8.2013, lng: 113.5323 },
|
lat: -8.243799,
|
||||||
{ kecamatan: "Kaliwates", lat: -8.1725, lng: 113.7000 },
|
lng: 113.642826,
|
||||||
{ kecamatan: "Puger", lat: -8.477942, lng: 113.370447 },
|
},
|
||||||
{ kecamatan: "Ambulu", lat: -8.381355, lng: 113.608561 },
|
{
|
||||||
{ kecamatan: "Silo", lat: -8.22917, lng: 113.86500 },
|
kecamatan: "Ambulu",
|
||||||
{ kecamatan: "Sumberbaru", lat: -8.119229, lng: 113.392973 },
|
lat: -8.3456,
|
||||||
{ kecamatan: "Patrang", lat: -8.14611, lng: 113.71250 },
|
lng: 113.6106,
|
||||||
{ kecamatan: "Tanggul", lat: -8.161572, lng: 113.451239 },
|
},
|
||||||
{ kecamatan: "Jenggawah", lat: -8.296967, lng: 113.656173 },
|
{
|
||||||
{ kecamatan: "Gumukmas", lat: -8.32306, lng: 113.40667 },
|
kecamatan: "Arjasa",
|
||||||
{ kecamatan: "Rambipuji", lat: -8.210581, lng: 113.603610 },
|
lat: -8.1042,
|
||||||
{ kecamatan: "Ajung", lat: -8.245360, lng: 113.653218 },
|
lng: 113.7398,
|
||||||
{ kecamatan: "Balung", lat: -8.26861, lng: 113.52667 },
|
},
|
||||||
{ kecamatan: "Tempurejo", lat: -8.4000, lng: 113.8000 },
|
{
|
||||||
{ kecamatan: "Kalisat", lat: -8.1500, lng: 113.8000 },
|
kecamatan: "Balung",
|
||||||
{ kecamatan: "Umbulsari", lat: -8.2500, lng: 113.5000 },
|
lat: -8.2592,
|
||||||
{ kecamatan: "Kencong", lat: -8.3500, lng: 113.4000 },
|
lng: 113.5464,
|
||||||
{ kecamatan: "Ledokombo", lat: -8.2000, lng: 113.8000 },
|
},
|
||||||
{ kecamatan: "Mumbulsari", lat: -8.3000, lng: 113.6000 },
|
{
|
||||||
{ kecamatan: "Panti", lat: -8.1500, lng: 113.6000 },
|
kecamatan: "Bangsalsari",
|
||||||
{ kecamatan: "Sumberjambe", lat: -8.1000, lng: 113.8000 },
|
lat: -8.1721,
|
||||||
{ kecamatan: "Sukowono", lat: -8.2000, lng: 113.7000 },
|
lng: 113.5312,
|
||||||
{ kecamatan: "Mayang", lat: -8.2500, lng: 113.7000 },
|
},
|
||||||
{ kecamatan: "Semboro", lat: -8.3000, lng: 113.5000 },
|
{
|
||||||
{ kecamatan: "Jombang", lat: -8.3500, lng: 113.5000 },
|
kecamatan: "Gumukmas",
|
||||||
{ kecamatan: "Pakusari", lat: -8.2000, lng: 113.7000 },
|
lat: -8.3117,
|
||||||
{ kecamatan: "Arjasa", lat: -8.1500, lng: 113.7000 },
|
lng: 113.4297,
|
||||||
{ kecamatan: "Sukorambi", lat: -8.2000, lng: 113.6000 },
|
},
|
||||||
{ kecamatan: "Jelbuk", lat: -8.1000, lng: 113.7000 },
|
{
|
||||||
|
kecamatan: "Jelbuk",
|
||||||
|
lat: -8.0831,
|
||||||
|
lng: 113.7649,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Jenggawah",
|
||||||
|
lat: -8.2409,
|
||||||
|
lng: 113.6407,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Jombang",
|
||||||
|
lat: -8.227065,
|
||||||
|
lng: 113.345340,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Kalisat",
|
||||||
|
lat: -8.1297,
|
||||||
|
lng: 113.8218,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Kaliwates",
|
||||||
|
lat: -8.1725,
|
||||||
|
lng: 113.6875,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Kencong",
|
||||||
|
lat: -8.2827,
|
||||||
|
lng: 113.3769,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Ledokombo",
|
||||||
|
lat: -8.1582,
|
||||||
|
lng: 113.9055,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Mayang",
|
||||||
|
lat: -8.1903,
|
||||||
|
lng: 113.8264,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Mumbulsari",
|
||||||
|
lat: -8.252744,
|
||||||
|
lng: 113.741842,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Panti",
|
||||||
|
lat: -8.1054,
|
||||||
|
lng: 113.6218,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Pakusari",
|
||||||
|
lat: -8.1308,
|
||||||
|
lng: 113.7577,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Patrang",
|
||||||
|
lat: -8.1402,
|
||||||
|
lng: 113.7128,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Puger",
|
||||||
|
lat: -8.314691,
|
||||||
|
lng: 113.474320,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Rambipuji",
|
||||||
|
lat: -8.2069,
|
||||||
|
lng: 113.6075,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Semboro",
|
||||||
|
lat: -8.1876,
|
||||||
|
lng: 113.4583,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Silo",
|
||||||
|
lat: -8.2276,
|
||||||
|
lng: 113.8757,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Sukorambi",
|
||||||
|
lat: -8.1094,
|
||||||
|
lng: 113.6589,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Sukowono",
|
||||||
|
lat: -8.0547,
|
||||||
|
lng: 113.8853,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Sumberbaru",
|
||||||
|
lat: -8.1362,
|
||||||
|
lng: 113.3879,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Sumberjambe",
|
||||||
|
lat: -8.0634,
|
||||||
|
lng: 113.9351,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Sumbersari",
|
||||||
|
lat: -8.1834,
|
||||||
|
lng: 113.7226,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Tanggul",
|
||||||
|
lat: -8.096066,
|
||||||
|
lng: 113.492787,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Tempurejo",
|
||||||
|
lat: -8.388294936847124,
|
||||||
|
lng: 113.73276107141628,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Umbulsari",
|
||||||
|
lat: -8.2623,
|
||||||
|
lng: 113.4633,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kecamatan: "Wuluhan",
|
||||||
|
lat: -8.3283,
|
||||||
|
lng: 113.5357,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -30,13 +30,13 @@ class DatabaseSeeder {
|
||||||
|
|
||||||
// Daftar semua seeders di sini
|
// Daftar semua seeders di sini
|
||||||
this.seeders = [
|
this.seeders = [
|
||||||
new RoleSeeder(prisma),
|
// new RoleSeeder(prisma),
|
||||||
new ResourceSeeder(prisma),
|
// new ResourceSeeder(prisma),
|
||||||
new PermissionSeeder(prisma),
|
// new PermissionSeeder(prisma),
|
||||||
new CrimeCategoriesSeeder(prisma),
|
// new CrimeCategoriesSeeder(prisma),
|
||||||
new GeoJSONSeeder(prisma),
|
// new GeoJSONSeeder(prisma),
|
||||||
new UnitSeeder(prisma),
|
// new UnitSeeder(prisma),
|
||||||
new DemographicsSeeder(prisma),
|
// new DemographicsSeeder(prisma),
|
||||||
new CrimesSeeder(prisma),
|
new CrimesSeeder(prisma),
|
||||||
// new CrimeIncidentsByUnitSeeder(prisma),
|
// new CrimeIncidentsByUnitSeeder(prisma),
|
||||||
new CrimeIncidentsByTypeSeeder(prisma),
|
new CrimeIncidentsByTypeSeeder(prisma),
|
||||||
|
|
|
@ -696,9 +696,6 @@ export class CrimeIncidentsByTypeSeeder {
|
||||||
console.log(`\n📊 ${year} Data Summary:`);
|
console.log(`\n📊 ${year} Data Summary:`);
|
||||||
console.log(`├─ Total incidents created: ${stats.incidents}`);
|
console.log(`├─ Total incidents created: ${stats.incidents}`);
|
||||||
console.log(`├─ Districts processed: ${stats.districts.size}`);
|
console.log(`├─ Districts processed: ${stats.districts.size}`);
|
||||||
console.log(
|
|
||||||
`├─ Categories: ${stats.matched} matched, ${stats.mismatched} mismatched`,
|
|
||||||
);
|
|
||||||
console.log(`└─ Total resolved cases: ${stats.resolved}`);
|
console.log(`└─ Total resolved cases: ${stats.resolved}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -731,22 +728,18 @@ export class CrimeIncidentsByTypeSeeder {
|
||||||
const centerLat = districtCenter.lat;
|
const centerLat = districtCenter.lat;
|
||||||
const centerLng = districtCenter.lng;
|
const centerLng = districtCenter.lng;
|
||||||
|
|
||||||
let scalingFactor = 0.3;
|
// Skala kecil: radius hanya 0.5 km dari center (sekitar 500 meter)
|
||||||
|
const radiusKm = 0.5;
|
||||||
const effectiveLandArea = Math.max(landArea || 1, 1);
|
|
||||||
|
|
||||||
const radiusKm = Math.sqrt(effectiveLandArea) * scalingFactor;
|
|
||||||
const radiusDeg = radiusKm / 111;
|
const radiusDeg = radiusKm / 111;
|
||||||
|
|
||||||
for (let i = 0; i < numPoints; i++) {
|
for (let i = 0; i < numPoints; i++) {
|
||||||
const angle = Math.random() * 2 * Math.PI;
|
const angle = Math.random() * 2 * Math.PI;
|
||||||
const distanceFactor = Math.pow(Math.random(), 1.5);
|
// Jarak random, lebih padat di tengah
|
||||||
const distance = distanceFactor * radiusDeg;
|
const distance = Math.pow(Math.random(), 1.5) * radiusDeg;
|
||||||
|
|
||||||
const latitude = centerLat + distance * Math.cos(angle);
|
const latitude = centerLat + distance * Math.cos(angle);
|
||||||
const longitude = centerLng +
|
const longitude = centerLng +
|
||||||
distance * Math.sin(angle) /
|
distance * Math.sin(angle) / Math.cos(centerLat * Math.PI / 180);
|
||||||
Math.cos(centerLat * Math.PI / 180);
|
|
||||||
|
|
||||||
const pointRadius = distance * 111000;
|
const pointRadius = distance * 111000;
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import {
|
import {
|
||||||
PrismaClient,
|
|
||||||
crime_incidents,
|
crime_incidents,
|
||||||
crime_rates,
|
crime_rates,
|
||||||
crimes,
|
crimes,
|
||||||
events,
|
events,
|
||||||
|
PrismaClient,
|
||||||
session_status,
|
session_status,
|
||||||
users,
|
users,
|
||||||
} from '@prisma/client';
|
} from "@prisma/client";
|
||||||
import fs from 'fs';
|
import fs from "fs";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
import { parse } from 'csv-parse/sync';
|
import { parse } from "csv-parse/sync";
|
||||||
import { generateId, generateIdWithDbCounter } from '../../app/_utils/common';
|
import { generateId, generateIdWithDbCounter } from "../../app/_utils/common";
|
||||||
import { CRegex } from '../../app/_utils/const/regex';
|
import { CRegex } from "../../app/_utils/const/regex";
|
||||||
|
import db from "../db";
|
||||||
|
|
||||||
interface ICreateUser {
|
interface ICreateUser {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -34,14 +34,17 @@ export class CrimesSeeder {
|
||||||
constructor(private prisma: PrismaClient) {}
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
|
||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
console.log('🌱 Seeding crimes data...');
|
console.log("🌱 Seeding crimes data...");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create test user
|
// Create test user
|
||||||
const user = await this.createUsers();
|
const user = await this.createUsers();
|
||||||
|
|
||||||
|
await db.crime_incidents.deleteMany();
|
||||||
|
await db.crimes.deleteMany();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('Failed to create user');
|
throw new Error("Failed to create user");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 5 events
|
// Create 5 events
|
||||||
|
@ -60,39 +63,39 @@ export class CrimesSeeder {
|
||||||
await this.importYearlyCrimeDataByType();
|
await this.importYearlyCrimeDataByType();
|
||||||
await this.importSummaryByType();
|
await this.importSummaryByType();
|
||||||
|
|
||||||
console.log('✅ Crime seeding completed successfully.');
|
console.log("✅ Crime seeding completed successfully.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error seeding crimes:', error);
|
console.error("❌ Error seeding crimes:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createUsers() {
|
private async createUsers() {
|
||||||
const existingUser = await this.prisma.users.findFirst({
|
const existingUser = await this.prisma.users.findFirst({
|
||||||
where: { email: 'sigapcompany@gmail.com' },
|
where: { email: "sigapcompany@gmail.com" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
console.log('Users already exist, skipping creation.');
|
console.log("Users already exist, skipping creation.");
|
||||||
return existingUser;
|
return existingUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
let roleId = await this.prisma.roles.findFirst({
|
let roleId = await this.prisma.roles.findFirst({
|
||||||
where: { name: 'admin' },
|
where: { name: "admin" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!roleId) {
|
if (!roleId) {
|
||||||
roleId = await this.prisma.roles.create({
|
roleId = await this.prisma.roles.create({
|
||||||
data: {
|
data: {
|
||||||
name: 'admin',
|
name: "admin",
|
||||||
description: 'Administrator role',
|
description: "Administrator role",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newUser = await this.prisma.users.create({
|
const newUser = await this.prisma.users.create({
|
||||||
data: {
|
data: {
|
||||||
email: 'sigapcompany@gmail.com',
|
email: "sigapcompany@gmail.com",
|
||||||
roles_id: roleId.id,
|
roles_id: roleId.id,
|
||||||
confirmed_at: new Date(),
|
confirmed_at: new Date(),
|
||||||
email_confirmed_at: new Date(),
|
email_confirmed_at: new Date(),
|
||||||
|
@ -106,9 +109,9 @@ export class CrimesSeeder {
|
||||||
is_anonymous: false,
|
is_anonymous: false,
|
||||||
profile: {
|
profile: {
|
||||||
create: {
|
create: {
|
||||||
first_name: 'Admin',
|
first_name: "Admin",
|
||||||
last_name: 'Sigap',
|
last_name: "Sigap",
|
||||||
username: 'adminsigap',
|
username: "adminsigap",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -125,7 +128,7 @@ export class CrimesSeeder {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingEvent) {
|
if (existingEvent) {
|
||||||
console.log('Events already exist, skipping creation.');
|
console.log("Events already exist, skipping creation.");
|
||||||
return existingEvent;
|
return existingEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -144,7 +147,7 @@ export class CrimesSeeder {
|
||||||
const existingSession = await this.prisma.sessions.findFirst();
|
const existingSession = await this.prisma.sessions.findFirst();
|
||||||
|
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
console.log('Sessions already exist, skipping creation.');
|
console.log("Sessions already exist, skipping creation.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,24 +172,24 @@ export class CrimesSeeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importMonthlyCrimeData() {
|
private async importMonthlyCrimeData() {
|
||||||
console.log('Importing monthly crime data...');
|
console.log("Importing monthly crime data...");
|
||||||
|
|
||||||
const existingCrimes = await this.prisma.crimes.findFirst({
|
const existingCrimes = await this.prisma.crimes.findFirst({
|
||||||
where: {
|
where: {
|
||||||
source_type: 'cbu',
|
source_type: "cbu",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingCrimes) {
|
if (existingCrimes) {
|
||||||
console.log('General crimes data already exists, skipping import.');
|
console.log("General crimes data already exists, skipping import.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const csvFilePath = path.resolve(
|
const csvFilePath = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../data/excels/crimes/crime_monthly_by_unit.csv'
|
"../data/excels/crimes/crime_monthly_by_unit.csv",
|
||||||
);
|
);
|
||||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||||
|
|
||||||
const records = parse(fileContent, {
|
const records = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
|
@ -201,30 +204,30 @@ export class CrimesSeeder {
|
||||||
|
|
||||||
const city = await this.prisma.cities.findFirst({
|
const city = await this.prisma.cities.findFirst({
|
||||||
where: {
|
where: {
|
||||||
name: 'Jember',
|
name: "Jember",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!city) {
|
if (!city) {
|
||||||
console.error('City not found: Jember');
|
console.error("City not found: Jember");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const year = parseInt(record.year);
|
const year = parseInt(record.year);
|
||||||
const crimeId = await generateIdWithDbCounter(
|
const crimeId = await generateIdWithDbCounter(
|
||||||
'crimes',
|
"crimes",
|
||||||
{
|
{
|
||||||
prefix: 'CR',
|
prefix: "CR",
|
||||||
segments: {
|
segments: {
|
||||||
codes: [city.id],
|
codes: [city.id],
|
||||||
sequentialDigits: 4,
|
sequentialDigits: 4,
|
||||||
year,
|
year,
|
||||||
},
|
},
|
||||||
format: '{prefix}-{codes}-{sequence}-{year}',
|
format: "{prefix}-{codes}-{sequence}-{year}",
|
||||||
separator: '-',
|
separator: "-",
|
||||||
uniquenessStrategy: 'counter',
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE
|
CRegex.CR_YEAR_SEQUENCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
|
@ -237,7 +240,7 @@ export class CrimesSeeder {
|
||||||
number_of_crime: parseInt(record.number_of_crime),
|
number_of_crime: parseInt(record.number_of_crime),
|
||||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||||
score: parseFloat(record.score),
|
score: parseFloat(record.score),
|
||||||
source_type: 'cbu',
|
source_type: "cbu",
|
||||||
});
|
});
|
||||||
|
|
||||||
processedDistricts.add(record.district_id);
|
processedDistricts.add(record.district_id);
|
||||||
|
@ -249,25 +252,25 @@ export class CrimesSeeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importYearlyCrimeData() {
|
private async importYearlyCrimeData() {
|
||||||
console.log('Importing yearly crime data...');
|
console.log("Importing yearly crime data...");
|
||||||
|
|
||||||
const existingYearlySummary = await this.prisma.crimes.findFirst({
|
const existingYearlySummary = await this.prisma.crimes.findFirst({
|
||||||
where: {
|
where: {
|
||||||
month: null,
|
month: null,
|
||||||
source_type: 'cbu',
|
source_type: "cbu",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingYearlySummary) {
|
if (existingYearlySummary) {
|
||||||
console.log('Yearly crime data already exists, skipping import.');
|
console.log("Yearly crime data already exists, skipping import.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const csvFilePath = path.resolve(
|
const csvFilePath = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../data/excels/crimes/crime_yearly_by_unit.csv'
|
"../data/excels/crimes/crime_yearly_by_unit.csv",
|
||||||
);
|
);
|
||||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||||
|
|
||||||
const records = parse(fileContent, {
|
const records = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
|
@ -296,33 +299,33 @@ export class CrimesSeeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
const crimeId = await generateIdWithDbCounter(
|
const crimeId = await generateIdWithDbCounter(
|
||||||
'crimes',
|
"crimes",
|
||||||
{
|
{
|
||||||
prefix: 'CR',
|
prefix: "CR",
|
||||||
segments: {
|
segments: {
|
||||||
codes: [city.id],
|
codes: [city.id],
|
||||||
sequentialDigits: 4,
|
sequentialDigits: 4,
|
||||||
year,
|
year,
|
||||||
},
|
},
|
||||||
format: '{prefix}-{codes}-{sequence}-{year}',
|
format: "{prefix}-{codes}-{sequence}-{year}",
|
||||||
separator: '-',
|
separator: "-",
|
||||||
uniquenessStrategy: 'counter',
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE
|
CRegex.CR_YEAR_SEQUENCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
id: crimeId,
|
id: crimeId,
|
||||||
district_id: record.district_id,
|
district_id: record.district_id,
|
||||||
level: crimeRate,
|
level: crimeRate,
|
||||||
method: record.method || 'kmeans',
|
method: record.method || "kmeans",
|
||||||
month: null,
|
month: null,
|
||||||
year: year,
|
year: year,
|
||||||
number_of_crime: parseInt(record.number_of_crime),
|
number_of_crime: parseInt(record.number_of_crime),
|
||||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||||
avg_crime: parseFloat(record.avg_crime) || 0,
|
avg_crime: parseFloat(record.avg_crime) || 0,
|
||||||
score: parseInt(record.score),
|
score: parseInt(record.score),
|
||||||
source_type: 'cbu',
|
source_type: "cbu",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -332,26 +335,26 @@ export class CrimesSeeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importAllYearSummaries() {
|
private async importAllYearSummaries() {
|
||||||
console.log('Importing all-year (2020-2024) crime summaries...');
|
console.log("Importing all-year (2020-2024) crime summaries...");
|
||||||
|
|
||||||
const existingAllYearSummaries = await this.prisma.crimes.findFirst({
|
const existingAllYearSummaries = await this.prisma.crimes.findFirst({
|
||||||
where: {
|
where: {
|
||||||
month: null,
|
month: null,
|
||||||
year: null,
|
year: null,
|
||||||
source_type: 'cbu',
|
source_type: "cbu",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingAllYearSummaries) {
|
if (existingAllYearSummaries) {
|
||||||
console.log('All-year crime summaries already exist, skipping import.');
|
console.log("All-year crime summaries already exist, skipping import.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const csvFilePath = path.resolve(
|
const csvFilePath = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../data/excels/crimes/crime_summary_by_unit.csv'
|
"../data/excels/crimes/crime_summary_by_unit.csv",
|
||||||
);
|
);
|
||||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||||
|
|
||||||
const records = parse(fileContent, {
|
const records = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
|
@ -380,32 +383,32 @@ export class CrimesSeeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
const crimeId = await generateIdWithDbCounter(
|
const crimeId = await generateIdWithDbCounter(
|
||||||
'crimes',
|
"crimes",
|
||||||
{
|
{
|
||||||
prefix: 'CR',
|
prefix: "CR",
|
||||||
segments: {
|
segments: {
|
||||||
codes: [city.id],
|
codes: [city.id],
|
||||||
sequentialDigits: 4,
|
sequentialDigits: 4,
|
||||||
},
|
},
|
||||||
format: '{prefix}-{codes}-{sequence}',
|
format: "{prefix}-{codes}-{sequence}",
|
||||||
separator: '-',
|
separator: "-",
|
||||||
uniquenessStrategy: 'counter',
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_SEQUENCE_END
|
CRegex.CR_SEQUENCE_END,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
id: crimeId,
|
id: crimeId,
|
||||||
district_id: districtId,
|
district_id: districtId,
|
||||||
level: crimeRate,
|
level: crimeRate,
|
||||||
method: 'kmeans',
|
method: "kmeans",
|
||||||
month: null,
|
month: null,
|
||||||
year: null,
|
year: null,
|
||||||
number_of_crime: parseInt(record.crime_total),
|
number_of_crime: parseInt(record.crime_total),
|
||||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||||
avg_crime: parseFloat(record.avg_crime) || 0,
|
avg_crime: parseFloat(record.avg_crime) || 0,
|
||||||
score: parseFloat(record.score),
|
score: parseFloat(record.score),
|
||||||
source_type: 'cbu',
|
source_type: "cbu",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -415,24 +418,24 @@ export class CrimesSeeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importMonthlyCrimeDataByType() {
|
private async importMonthlyCrimeDataByType() {
|
||||||
console.log('Importing monthly crime data by type...');
|
console.log("Importing monthly crime data by type...");
|
||||||
|
|
||||||
const existingCrimeByType = await this.prisma.crimes.findFirst({
|
const existingCrimeByType = await this.prisma.crimes.findFirst({
|
||||||
where: {
|
where: {
|
||||||
source_type: 'cbt',
|
source_type: "cbt",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingCrimeByType) {
|
if (existingCrimeByType) {
|
||||||
console.log('Crime data by type already exists, skipping import.');
|
console.log("Crime data by type already exists, skipping import.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const csvFilePath = path.resolve(
|
const csvFilePath = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../data/excels/crimes/crime_monthly_by_type.csv'
|
"../data/excels/crimes/crime_monthly_by_type.csv",
|
||||||
);
|
);
|
||||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||||
|
|
||||||
const records = parse(fileContent, {
|
const records = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
|
@ -446,43 +449,43 @@ export class CrimesSeeder {
|
||||||
|
|
||||||
const city = await this.prisma.cities.findFirst({
|
const city = await this.prisma.cities.findFirst({
|
||||||
where: {
|
where: {
|
||||||
name: 'Jember',
|
name: "Jember",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!city) {
|
if (!city) {
|
||||||
console.error('City not found: Jember');
|
console.error("City not found: Jember");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const year = parseInt(record.year);
|
const year = parseInt(record.year);
|
||||||
const crimeId = await generateIdWithDbCounter(
|
const crimeId = await generateIdWithDbCounter(
|
||||||
'crimes',
|
"crimes",
|
||||||
{
|
{
|
||||||
prefix: 'CR',
|
prefix: "CR",
|
||||||
segments: {
|
segments: {
|
||||||
codes: [city.id],
|
codes: [city.id],
|
||||||
sequentialDigits: 4,
|
sequentialDigits: 4,
|
||||||
year,
|
year,
|
||||||
},
|
},
|
||||||
format: '{prefix}-{codes}-{sequence}-{year}',
|
format: "{prefix}-{codes}-{sequence}-{year}",
|
||||||
separator: '-',
|
separator: "-",
|
||||||
uniquenessStrategy: 'counter',
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE
|
CRegex.CR_YEAR_SEQUENCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
id: crimeId,
|
id: crimeId,
|
||||||
district_id: record.district_id,
|
district_id: record.district_id,
|
||||||
level: crimeRate,
|
level: crimeRate,
|
||||||
method: record.method || 'kmeans',
|
method: record.method || "kmeans",
|
||||||
month: parseInt(record.month_num),
|
month: parseInt(record.month_num),
|
||||||
year: parseInt(record.year),
|
year: parseInt(record.year),
|
||||||
number_of_crime: parseInt(record.number_of_crime),
|
number_of_crime: parseInt(record.number_of_crime),
|
||||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||||
score: parseFloat(record.score),
|
score: parseFloat(record.score),
|
||||||
source_type: 'cbt',
|
source_type: "cbt",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -492,25 +495,25 @@ export class CrimesSeeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importYearlyCrimeDataByType() {
|
private async importYearlyCrimeDataByType() {
|
||||||
console.log('Importing yearly crime data by type...');
|
console.log("Importing yearly crime data by type...");
|
||||||
|
|
||||||
const existingYearlySummary = await this.prisma.crimes.findFirst({
|
const existingYearlySummary = await this.prisma.crimes.findFirst({
|
||||||
where: {
|
where: {
|
||||||
month: null,
|
month: null,
|
||||||
source_type: 'cbt',
|
source_type: "cbt",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingYearlySummary) {
|
if (existingYearlySummary) {
|
||||||
console.log('Yearly crime data by type already exists, skipping import.');
|
console.log("Yearly crime data by type already exists, skipping import.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const csvFilePath = path.resolve(
|
const csvFilePath = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../data/excels/crimes/crime_yearly_by_type.csv'
|
"../data/excels/crimes/crime_yearly_by_type.csv",
|
||||||
);
|
);
|
||||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||||
|
|
||||||
const records = parse(fileContent, {
|
const records = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
|
@ -539,33 +542,33 @@ export class CrimesSeeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
const crimeId = await generateIdWithDbCounter(
|
const crimeId = await generateIdWithDbCounter(
|
||||||
'crimes',
|
"crimes",
|
||||||
{
|
{
|
||||||
prefix: 'CR',
|
prefix: "CR",
|
||||||
segments: {
|
segments: {
|
||||||
codes: [city.id],
|
codes: [city.id],
|
||||||
sequentialDigits: 4,
|
sequentialDigits: 4,
|
||||||
year,
|
year,
|
||||||
},
|
},
|
||||||
format: '{prefix}-{codes}-{sequence}-{year}',
|
format: "{prefix}-{codes}-{sequence}-{year}",
|
||||||
separator: '-',
|
separator: "-",
|
||||||
uniquenessStrategy: 'counter',
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_YEAR_SEQUENCE
|
CRegex.CR_YEAR_SEQUENCE,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
id: crimeId,
|
id: crimeId,
|
||||||
district_id: record.district_id,
|
district_id: record.district_id,
|
||||||
level: crimeRate,
|
level: crimeRate,
|
||||||
method: record.method || 'kmeans',
|
method: record.method || "kmeans",
|
||||||
month: null,
|
month: null,
|
||||||
year: year,
|
year: year,
|
||||||
number_of_crime: parseInt(record.number_of_crime),
|
number_of_crime: parseInt(record.number_of_crime),
|
||||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||||
avg_crime: parseFloat(record.avg_crime) || 0,
|
avg_crime: parseFloat(record.avg_crime) || 0,
|
||||||
score: parseInt(record.score),
|
score: parseInt(record.score),
|
||||||
source_type: 'cbt',
|
source_type: "cbt",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -575,24 +578,24 @@ export class CrimesSeeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async importSummaryByType() {
|
private async importSummaryByType() {
|
||||||
console.log('Importing crime summary by type...');
|
console.log("Importing crime summary by type...");
|
||||||
|
|
||||||
const existingSummary = await this.prisma.crimes.findFirst({
|
const existingSummary = await this.prisma.crimes.findFirst({
|
||||||
where: {
|
where: {
|
||||||
source_type: 'cbt',
|
source_type: "cbt",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingSummary) {
|
if (existingSummary) {
|
||||||
console.log('Crime summary by type already exists, skipping import.');
|
console.log("Crime summary by type already exists, skipping import.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const csvFilePath = path.resolve(
|
const csvFilePath = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
'../data/excels/crimes/crime_summary_by_type.csv'
|
"../data/excels/crimes/crime_summary_by_type.csv",
|
||||||
);
|
);
|
||||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||||
|
|
||||||
const records = parse(fileContent, {
|
const records = parse(fileContent, {
|
||||||
columns: true,
|
columns: true,
|
||||||
|
@ -606,42 +609,42 @@ export class CrimesSeeder {
|
||||||
|
|
||||||
const city = await this.prisma.cities.findFirst({
|
const city = await this.prisma.cities.findFirst({
|
||||||
where: {
|
where: {
|
||||||
name: 'Jember',
|
name: "Jember",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!city) {
|
if (!city) {
|
||||||
console.error('City not found: Jember');
|
console.error("City not found: Jember");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const crimeId = await generateIdWithDbCounter(
|
const crimeId = await generateIdWithDbCounter(
|
||||||
'crimes',
|
"crimes",
|
||||||
{
|
{
|
||||||
prefix: 'CR',
|
prefix: "CR",
|
||||||
segments: {
|
segments: {
|
||||||
codes: [city.id],
|
codes: [city.id],
|
||||||
sequentialDigits: 4,
|
sequentialDigits: 4,
|
||||||
},
|
},
|
||||||
format: '{prefix}-{codes}-{sequence}',
|
format: "{prefix}-{codes}-{sequence}",
|
||||||
separator: '-',
|
separator: "-",
|
||||||
uniquenessStrategy: 'counter',
|
uniquenessStrategy: "counter",
|
||||||
},
|
},
|
||||||
CRegex.CR_SEQUENCE_END
|
CRegex.CR_SEQUENCE_END,
|
||||||
);
|
);
|
||||||
|
|
||||||
crimesData.push({
|
crimesData.push({
|
||||||
id: crimeId,
|
id: crimeId,
|
||||||
district_id: record.district_id,
|
district_id: record.district_id,
|
||||||
level: crimeRate,
|
level: crimeRate,
|
||||||
method: 'kmeans',
|
method: "kmeans",
|
||||||
month: null,
|
month: null,
|
||||||
year: null,
|
year: null,
|
||||||
number_of_crime: parseInt(record.crime_total),
|
number_of_crime: parseInt(record.crime_total),
|
||||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||||
avg_crime: parseFloat(record.avg_crime) || 0,
|
avg_crime: parseFloat(record.avg_crime) || 0,
|
||||||
score: parseFloat(record.score),
|
score: parseFloat(record.score),
|
||||||
source_type: 'cbt',
|
source_type: "cbt",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -659,7 +662,7 @@ if (require.main === module) {
|
||||||
try {
|
try {
|
||||||
await seeder.run();
|
await seeder.run();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error during seeding:', e);
|
console.error("Error during seeding:", e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|
Loading…
Reference in New Issue