commit 278a31a6509d8e5bfadcf2e131822bba19c1ba0b Author: Jeff Emmett Date: Fri Feb 13 12:19:54 2026 -0700 feat: initial cal-shared package with types, API client, IFC logic, and hooks Shared package for cal.jeffemmett.com and zoomcal.jeffemmett.com containing: - TypeScript types and enums (calendar, spatial, temporal) - API client functions (events, sources, locations, IFC) - IFC calendar conversion utilities - Semantic location label utilities - React Query hooks (useEvents, useMonthEvents, useSources) - QueryClientProvider setup Co-Authored-By: Claude Opus 4.6 diff --git a/package.json b/package.json new file mode 100644 index 0000000..37a65b7 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "@cal/shared", + "version": "1.0.0", + "description": "Shared types, API client, IFC logic, and hooks for cal and zoomcal", + "main": "src/index.ts", + "types": "src/index.ts", + "peerDependencies": { + "react": "^18.2.0", + "@tanstack/react-query": "^5.17.0" + } +} diff --git a/src/hooks/useEvents.ts b/src/hooks/useEvents.ts new file mode 100644 index 0000000..5db4787 --- /dev/null +++ b/src/hooks/useEvents.ts @@ -0,0 +1,67 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { getEvents, getSources } from '../lib/api' +import { EventListItem, CalendarSource } from '../types' + +interface EventsResponse { + count: number + next: string | null + previous: string | null + results: EventListItem[] +} + +interface SourcesResponse { + count: number + next: string | null + previous: string | null + results: CalendarSource[] +} + +export function useEvents(params?: { + start?: string + end?: string + source?: string + ifc_month?: number + ifc_year?: number +}) { + return useQuery({ + queryKey: ['events', params], + queryFn: () => getEvents(params) as Promise, + staleTime: 5 * 60 * 1000, // 5 minutes + }) +} + +export function useMonthEvents(year: number, month: number) { + // Get first and last day of month + const start = new Date(year, month - 1, 1).toISOString().split('T')[0] + const end = new Date(year, month, 0).toISOString().split('T')[0] + + return useQuery({ + queryKey: ['events', 'month', year, month], + queryFn: () => getEvents({ start, end }) as Promise, + staleTime: 5 * 60 * 1000, + }) +} + +export function useSources() { + return useQuery({ + queryKey: ['sources'], + queryFn: () => getSources() as Promise, + staleTime: 10 * 60 * 1000, // 10 minutes + }) +} + +// Group events by date for calendar display +export function groupEventsByDate(events: EventListItem[]): Map { + const grouped = new Map() + + for (const event of events) { + const dateKey = event.start.split('T')[0] + const existing = grouped.get(dateKey) || [] + existing.push(event) + grouped.set(dateKey, existing) + } + + return grouped +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..60499d1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +// Types +export * from './types' + +// Lib +export * from './lib/ifc' +export * from './lib/api' +export * from './lib/location' + +// Hooks +export * from './hooks/useEvents' + +// Providers +export { Providers } from './providers' diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..55ae11d --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,188 @@ +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1' + +async function fetcher(url: string, options?: RequestInit): Promise { + const response = await fetch(`${API_URL}${url}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }) + + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + + return response.json() +} + +// Events API + +export async function getEvents(params?: { + start?: string + end?: string + source?: string + location?: string + ifc_month?: number + ifc_year?: number + search?: string +}) { + const searchParams = new URLSearchParams() + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, String(value)) + } + }) + } + + const query = searchParams.toString() + return fetcher(`/events${query ? `?${query}` : ''}`) +} + +export async function getEvent(id: string) { + return fetcher(`/events/${id}/`) +} + +export async function getUpcomingEvents(days = 7) { + return fetcher(`/events/upcoming/?days=${days}`) +} + +export async function getTodayEvents() { + return fetcher(`/events/today/`) +} + +export async function getEventsByIFCMonth(year: number, month?: number) { + const params = month ? `?year=${year}&month=${month}` : `?year=${year}` + return fetcher(`/events/by_ifc_month/${params}`) +} + +export async function getCalendarView(params: { + year: number + month: number + type?: 'gregorian' | 'ifc' +}) { + const searchParams = new URLSearchParams({ + year: String(params.year), + month: String(params.month), + type: params.type || 'gregorian', + }) + return fetcher(`/events/calendar_view/?${searchParams}`) +} + +// Sources API + +export async function getSources(params?: { + is_active?: boolean + is_visible?: boolean + source_type?: string +}) { + const searchParams = new URLSearchParams() + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, String(value)) + } + }) + } + + const query = searchParams.toString() + return fetcher(`/sources${query ? `?${query}` : ''}`) +} + +export async function getSource(id: string) { + return fetcher(`/sources/${id}/`) +} + +export async function syncSource(id: string) { + return fetcher(`/sources/${id}/sync/`, { method: 'POST' }) +} + +export async function syncAllSources() { + // Get all active sources and sync them in parallel + const response = await getSources({ is_active: true }) as { results: Array<{ id: string }> } + const syncPromises = response.results.map((source) => syncSource(source.id)) + return Promise.allSettled(syncPromises) +} + +// Locations API + +export async function getLocations(params?: { + granularity?: number + parent?: string + search?: string +}) { + const searchParams = new URLSearchParams() + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, String(value)) + } + }) + } + + const query = searchParams.toString() + return fetcher(`/locations/${query ? `?${query}` : ''}`) +} + +export async function getLocation(slug: string) { + return fetcher(`/locations/${slug}/`) +} + +export async function getLocationRoots() { + return fetcher(`/locations/roots/`) +} + +export async function getLocationTree() { + return fetcher(`/locations/tree/`) +} + +export async function getLocationChildren(slug: string) { + return fetcher(`/locations/${slug}/children/`) +} + +export async function getLocationEvents(slug: string, includeDescendants = true) { + return fetcher(`/locations/${slug}/events/?include_descendants=${includeDescendants}`) +} + +// IFC API + +export async function convertToIFC(date: string) { + return fetcher(`/ifc/convert/?direction=to_ifc&date=${date}`) +} + +export async function convertToGregorian(params: { + year: number + month: number + day: number + is_year_day?: boolean + is_leap_day?: boolean +}) { + const searchParams = new URLSearchParams({ + direction: 'to_gregorian', + year: String(params.year), + month: String(params.month), + day: String(params.day), + }) + if (params.is_year_day) searchParams.append('is_year_day', 'true') + if (params.is_leap_day) searchParams.append('is_leap_day', 'true') + + return fetcher(`/ifc/convert/?${searchParams}`) +} + +export async function getIFCCalendar(year: number, month?: number) { + const params = month ? `?year=${year}&month=${month}` : `?year=${year}` + return fetcher(`/ifc/calendar/${params}`) +} + +// Stats API + +export async function getCalendarStats() { + return fetcher(`/stats/`) +} + +// Granularity API + +export async function getSpatialGranularities() { + return fetcher(`/granularities/`) +} diff --git a/src/lib/ifc.ts b/src/lib/ifc.ts new file mode 100644 index 0000000..addcfe4 --- /dev/null +++ b/src/lib/ifc.ts @@ -0,0 +1,197 @@ +/** + * International Fixed Calendar (IFC) utilities for client-side conversion. + * + * The IFC is a proposed calendar reform with: + * - 13 months of 28 days each (364 days) + * - 1 "Year Day" after December 28 (no weekday) + * - 1 "Leap Day" after June 28 in leap years (no weekday) + * - Every month starts on Sunday, ends on Saturday + */ + +import { IFCDate, IFC_MONTHS, IFC_WEEKDAYS } from '../types' + +export function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 +} + +export function getDayOfYear(date: Date): number { + const start = new Date(date.getFullYear(), 0, 0) + const diff = date.getTime() - start.getTime() + const oneDay = 1000 * 60 * 60 * 24 + return Math.floor(diff / oneDay) +} + +export function gregorianToIFC(date: Date): IFCDate { + const year = date.getFullYear() + const dayOfYear = getDayOfYear(date) + const leap = isLeapYear(year) + + // Handle Year Day (last day of year - day 365 or 366) + const lastDay = leap ? 366 : 365 + if (dayOfYear === lastDay) { + return { + year, + month: 13, + day: 29, // Special "29th" of December + weekday: -1, // No weekday + is_year_day: true, + is_leap_day: false, + month_name: 'Year Day', + } + } + + // Handle Leap Day (day 169 in leap years = Jun 17 Gregorian) + if (leap && dayOfYear === 169) { + return { + year, + month: 6, // After June + day: 29, // Special "29th" + weekday: -1, // No weekday + is_year_day: false, + is_leap_day: true, + month_name: 'Leap Day', + } + } + + // Adjust day count: remove leap day from count if we're past it + let adjustedDay = dayOfYear + if (leap && dayOfYear > 169) { + adjustedDay -= 1 + } + + // Calculate IFC month and day + const ifcMonth = Math.floor((adjustedDay - 1) / 28) + 1 + const ifcDay = ((adjustedDay - 1) % 28) + 1 + + // Weekday: In IFC, day 1 of every month is Sunday + const ifcWeekday = (ifcDay - 1) % 7 + + return { + year, + month: ifcMonth, + day: ifcDay, + weekday: ifcWeekday, + is_year_day: false, + is_leap_day: false, + month_name: IFC_MONTHS[ifcMonth - 1], + } +} + +export function ifcToGregorian(ifc: IFCDate): Date { + const year = ifc.year + const leap = isLeapYear(year) + + // Handle special days + if (ifc.is_year_day) { + return new Date(year, 11, 31) // Dec 31 + } + + if (ifc.is_leap_day) { + return new Date(year, 5, 17) // Jun 17 + } + + // Calculate day of year from IFC month/day + const ifcDayOfYear = (ifc.month - 1) * 28 + ifc.day + + // Adjust for leap day if applicable + let gregorianDayOfYear = ifcDayOfYear + if (leap && ifcDayOfYear >= 169) { + gregorianDayOfYear += 1 + } + + // Convert day of year to date + const result = new Date(year, 0, 1) + result.setDate(gregorianDayOfYear) + return result +} + +export function formatIFCDate(ifc: IFCDate, format: 'full' | 'medium' | 'short' | 'numeric' = 'full'): string { + if (ifc.is_year_day) { + if (format === 'numeric') return `${ifc.year}-YD` + return `Year Day, ${ifc.year}` + } + + if (ifc.is_leap_day) { + if (format === 'numeric') return `${ifc.year}-LD` + return `Leap Day, ${ifc.year}` + } + + switch (format) { + case 'full': { + const weekday = ifc.weekday >= 0 ? IFC_WEEKDAYS[ifc.weekday] : '' + return `${weekday}, ${ifc.month_name} ${ifc.day}, ${ifc.year}` + } + case 'medium': { + const weekdayShort = ifc.weekday >= 0 ? IFC_WEEKDAYS[ifc.weekday].slice(0, 3) : '' + const monthShort = ifc.month_name.slice(0, 3) + return `${weekdayShort}, ${monthShort} ${ifc.day}, ${ifc.year}` + } + case 'short': { + const monthShort = ifc.month_name.slice(0, 3) + return `${monthShort} ${ifc.day}` + } + case 'numeric': + return `${ifc.year}-${String(ifc.month).padStart(2, '0')}-${String(ifc.day).padStart(2, '0')}` + default: + return formatIFCDate(ifc, 'full') + } +} + +export function getIFCMonthCalendar(year: number, month: number): IFCDate[][] { + const grid: IFCDate[][] = [] + + for (let week = 0; week < 4; week++) { + const weekDays: IFCDate[] = [] + for (let dayInWeek = 0; dayInWeek < 7; dayInWeek++) { + const day = week * 7 + dayInWeek + 1 + weekDays.push({ + year, + month, + day, + weekday: dayInWeek, + is_year_day: false, + is_leap_day: false, + month_name: IFC_MONTHS[month - 1], + }) + } + grid.push(weekDays) + } + + return grid +} + +export function getIFCWeekdayName(weekday: number): string { + if (weekday < 0) return 'N/A' + return IFC_WEEKDAYS[weekday] +} + +export function getIFCMonthName(month: number): string { + if (month >= 1 && month <= 13) { + return IFC_MONTHS[month - 1] + } + return 'Unknown' +} + +export function getTodayIFC(): IFCDate { + return gregorianToIFC(new Date()) +} + +// Get the IFC month color for styling +export function getIFCMonthColor(month: number): string { + const colors: Record = { + 1: '#ef4444', // January - Red + 2: '#f97316', // February - Orange + 3: '#eab308', // March - Yellow + 4: '#22c55e', // April - Green + 5: '#14b8a6', // May - Teal + 6: '#0ea5e9', // June - Sky + 7: '#f59e0b', // Sol - Amber + 8: '#3b82f6', // July - Blue + 9: '#6366f1', // August - Indigo + 10: '#8b5cf6', // September - Violet + 11: '#a855f7', // October - Purple + 12: '#ec4899', // November - Pink + 13: '#78716c', // December - Stone + } + return colors[month] || '#6b7280' +} diff --git a/src/lib/location.ts b/src/lib/location.ts new file mode 100644 index 0000000..6876a0a --- /dev/null +++ b/src/lib/location.ts @@ -0,0 +1,83 @@ +import { SpatialGranularity, UnifiedEvent, EventListItem } from '../types' + +/** + * Returns the best location string for an event given the current spatial zoom level. + * + * At broad zooms (PLANET/CONTINENT), returns the continent/country from breadcrumb. + * At medium zooms (COUNTRY/REGION/CITY), returns the city or country. + * At fine zooms (NEIGHBORHOOD/ADDRESS/COORDINATES), returns the full address. + * + * Falls back gracefully through location_display → location_raw. + */ +export function getSemanticLocationLabel( + event: UnifiedEvent | EventListItem, + spatialGranularity: SpatialGranularity +): string { + // EventListItem only has location_raw + if (!('location_display' in event)) { + return (event as EventListItem).location_raw || '' + } + + const e = event as UnifiedEvent + + if (!e.location_raw && !e.location_display) { + return e.is_virtual ? (e.virtual_platform || 'Virtual') : '' + } + + // Use breadcrumb for structured parsing + // breadcrumb format: "Earth > Europe > Germany > Munich > Dachauer Str. 7" + const crumbs = e.location_breadcrumb?.split(' > ') || [] + + if (crumbs.length === 0) { + return e.location_display || e.location_raw || '' + } + + switch (spatialGranularity) { + case SpatialGranularity.PLANET: + return crumbs[0] || 'Earth' + + case SpatialGranularity.CONTINENT: + return crumbs[1] || crumbs[0] || e.location_display || '' + + case SpatialGranularity.BIOREGION: + case SpatialGranularity.COUNTRY: + return crumbs[2] || crumbs[1] || e.location_display || '' + + case SpatialGranularity.REGION: + return crumbs[3] || crumbs[2] || e.location_display || '' + + case SpatialGranularity.CITY: + return crumbs[3] || crumbs[2] || e.location_display || e.location_raw + + case SpatialGranularity.NEIGHBORHOOD: + return crumbs.slice(-2).join(', ') || e.location_display || e.location_raw + + case SpatialGranularity.ADDRESS: + case SpatialGranularity.COORDINATES: + return e.location_raw || e.location_display || '' + + default: + return e.location_display || e.location_raw || '' + } +} + +/** + * Groups events by their semantic location at the given granularity. + * Useful for clustering markers on the map at broad zoom levels. + */ +export function groupEventsByLocation( + events: UnifiedEvent[], + spatialGranularity: SpatialGranularity +): Map { + const grouped = new Map() + + for (const event of events) { + const label = getSemanticLocationLabel(event, spatialGranularity) + if (!label) continue + const existing = grouped.get(label) || [] + existing.push(event) + grouped.set(label, existing) + } + + return grouped +} diff --git a/src/providers/index.tsx b/src/providers/index.tsx new file mode 100644 index 0000000..f5c6eb7 --- /dev/null +++ b/src/providers/index.tsx @@ -0,0 +1,24 @@ +'use client' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + refetchOnWindowFocus: false, + }, + }, + }) + ) + + return ( + + {children} + + ) +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..fa9da74 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,284 @@ +// Calendar Types + +export type CalendarType = 'gregorian' | 'ifc' + +export type ViewType = 'month' | 'week' | 'day' | 'year' | 'timeline' + +// Temporal Granularity - zoom levels for time navigation +export enum TemporalGranularity { + MOMENT = 0, // Real-time / Now view + HOUR = 1, // Hourly schedule blocks + DAY = 2, // Day planner + WEEK = 3, // Week view + MONTH = 4, // Month view (default) + SEASON = 5, // Quarterly / 91-day IFC quarters + YEAR = 6, // Annual glance view + DECADE = 7, // 10-year strategic view + CENTURY = 8, // Historical / long-term view + COSMIC = 9, // Deep time / geological ages +} + +export const TEMPORAL_GRANULARITY_LABELS: Record = { + [TemporalGranularity.MOMENT]: 'Moment', + [TemporalGranularity.HOUR]: 'Hour', + [TemporalGranularity.DAY]: 'Day', + [TemporalGranularity.WEEK]: 'Week', + [TemporalGranularity.MONTH]: 'Month', + [TemporalGranularity.SEASON]: 'Season', + [TemporalGranularity.YEAR]: 'Year', + [TemporalGranularity.DECADE]: 'Decade', + [TemporalGranularity.CENTURY]: 'Century', + [TemporalGranularity.COSMIC]: 'Cosmic', +} + +// Map temporal granularity to ViewType for rendering +export const TEMPORAL_TO_VIEW: Partial> = { + [TemporalGranularity.DAY]: 'day', + [TemporalGranularity.WEEK]: 'week', + [TemporalGranularity.MONTH]: 'month', + [TemporalGranularity.YEAR]: 'year', + [TemporalGranularity.DECADE]: 'timeline', +} + +// IFC Season/Quarter definitions +export interface IFCSeason { + quarter: 1 | 2 | 3 | 4 + name: string + months: number[] // IFC month numbers + startDay: number // Day of year (1-364) + endDay: number +} + +export const IFC_SEASONS: IFCSeason[] = [ + { quarter: 1, name: 'Spring', months: [1, 2, 3], startDay: 1, endDay: 91 }, + { quarter: 2, name: 'Summer', months: [4, 5, 6, 7], startDay: 92, endDay: 182 }, // Includes Sol + { quarter: 3, name: 'Autumn', months: [8, 9, 10], startDay: 183, endDay: 273 }, + { quarter: 4, name: 'Winter', months: [11, 12, 13], startDay: 274, endDay: 364 }, +] + +export interface IFCDate { + year: number + month: number // 1-13 + day: number // 1-28 (or 29 for special days) + weekday: number // 0-6 (Sun-Sat), -1 for special days + is_year_day: boolean + is_leap_day: boolean + month_name: string +} + +export const IFC_MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'Sol', // New month between June and July + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +] as const + +export const IFC_WEEKDAYS = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +] as const + +// Spatial Types + +export enum SpatialGranularity { + PLANET = 0, + CONTINENT = 1, + BIOREGION = 2, + COUNTRY = 3, + REGION = 4, + CITY = 5, + NEIGHBORHOOD = 6, + ADDRESS = 7, + COORDINATES = 8, +} + +export const GRANULARITY_LABELS: Record = { + [SpatialGranularity.PLANET]: 'Planet', + [SpatialGranularity.CONTINENT]: 'Continent', + [SpatialGranularity.BIOREGION]: 'Bioregion', + [SpatialGranularity.COUNTRY]: 'Country', + [SpatialGranularity.REGION]: 'Region', + [SpatialGranularity.CITY]: 'City', + [SpatialGranularity.NEIGHBORHOOD]: 'Neighborhood', + [SpatialGranularity.ADDRESS]: 'Address', + [SpatialGranularity.COORDINATES]: 'Coordinates', +} + +export interface Location { + id: string + name: string + slug: string + granularity: SpatialGranularity + granularity_display: string + parent: string | null + path: string + depth: number + breadcrumb: string + latitude: number | null + longitude: number | null + coordinates: { latitude: number; longitude: number } | null + timezone_str: string + children_count: number +} + +export interface CalendarSource { + id: string + name: string + source_type: 'google' | 'ics' | 'caldav' | 'outlook' | 'apple' | 'manual' | 'obsidian' + source_type_display: string + color: string + is_visible: boolean + is_active: boolean + last_synced_at: string | null + sync_error: string + event_count: number +} + +export interface UnifiedEvent { + id: string + source: string + source_name: string + source_color: string + source_type: string + external_id: string + title: string + description: string + // Gregorian time + start: string + end: string + all_day: boolean + timezone_str: string + rrule: string + is_recurring: boolean + // IFC time + ifc_start_year: number | null + ifc_start_month: number | null + ifc_start_day: number | null + ifc_start_weekday: number | null + ifc_is_year_day: boolean + ifc_is_leap_day: boolean + ifc_date: IFCDate | null + ifc_display: string | null + // Location + location: string | null + location_raw: string + location_display: string | null + location_breadcrumb: string | null + latitude: number | null + longitude: number | null + coordinates: { latitude: number; longitude: number } | null + location_granularity: SpatialGranularity | null + // Virtual + is_virtual: boolean + virtual_url: string + virtual_platform: string + // Participants + organizer_name: string + organizer_email: string + attendees: Array<{ email: string; name?: string; status?: string }> + attendee_count: number + // Status + status: 'confirmed' | 'tentative' | 'cancelled' + visibility: 'default' | 'public' | 'private' + // Computed + duration_minutes: number + is_upcoming: boolean + is_ongoing: boolean +} + +export interface EventListItem { + id: string + title: string + start: string + end: string + all_day: boolean + source: string + source_color: string + location_raw: string + is_virtual: boolean + status: string + ifc_start_month: number | null + ifc_start_day: number | null + ifc_display: string | null +} + +// Calendar View Types + +export interface CalendarDay { + date: Date + day: number + isCurrentMonth: boolean + isToday: boolean + events: EventListItem[] + // IFC specific + ifc?: IFCDate +} + +export interface CalendarMonth { + year: number + month: number + month_name: string + days: CalendarDay[] +} + +// ── Temporal → Spatial Coupling ── + +/** Maps each TemporalGranularity to a "natural" SpatialGranularity for coupled zoom. */ +export const TEMPORAL_TO_SPATIAL: Record = { + [TemporalGranularity.MOMENT]: SpatialGranularity.COORDINATES, + [TemporalGranularity.HOUR]: SpatialGranularity.ADDRESS, + [TemporalGranularity.DAY]: SpatialGranularity.ADDRESS, + [TemporalGranularity.WEEK]: SpatialGranularity.CITY, + [TemporalGranularity.MONTH]: SpatialGranularity.COUNTRY, + [TemporalGranularity.SEASON]: SpatialGranularity.COUNTRY, + [TemporalGranularity.YEAR]: SpatialGranularity.CONTINENT, + [TemporalGranularity.DECADE]: SpatialGranularity.CONTINENT, + [TemporalGranularity.CENTURY]: SpatialGranularity.PLANET, + [TemporalGranularity.COSMIC]: SpatialGranularity.PLANET, +} + +/** Maps SpatialGranularity to Leaflet zoom levels (0 = whole world, 18 = building). */ +export const SPATIAL_TO_LEAFLET_ZOOM: Record = { + [SpatialGranularity.PLANET]: 2, + [SpatialGranularity.CONTINENT]: 4, + [SpatialGranularity.BIOREGION]: 5, + [SpatialGranularity.COUNTRY]: 6, + [SpatialGranularity.REGION]: 8, + [SpatialGranularity.CITY]: 11, + [SpatialGranularity.NEIGHBORHOOD]:14, + [SpatialGranularity.ADDRESS]: 16, + [SpatialGranularity.COORDINATES]: 18, +} + +/** Reverse mapping: Leaflet zoom → closest SpatialGranularity. */ +export function leafletZoomToSpatial(zoom: number): SpatialGranularity { + if (zoom <= 2) return SpatialGranularity.PLANET + if (zoom <= 4) return SpatialGranularity.CONTINENT + if (zoom <= 5) return SpatialGranularity.BIOREGION + if (zoom <= 7) return SpatialGranularity.COUNTRY + if (zoom <= 9) return SpatialGranularity.REGION + if (zoom <= 12) return SpatialGranularity.CITY + if (zoom <= 15) return SpatialGranularity.NEIGHBORHOOD + if (zoom <= 17) return SpatialGranularity.ADDRESS + return SpatialGranularity.COORDINATES +} + +/** Map state for the Leaflet map component. */ +export interface MapState { + center: [number, number] // [lat, lng] + zoom: number // Leaflet zoom level +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..616ae36 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": {} + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules"] +}