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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 12:19:54 -07:00
commit 278a31a650
9 changed files with 887 additions and 0 deletions

11
package.json Normal file
View File

@ -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"
}
}

67
src/hooks/useEvents.ts Normal file
View File

@ -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<EventsResponse>({
queryKey: ['events', params],
queryFn: () => getEvents(params) as Promise<EventsResponse>,
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<EventsResponse>({
queryKey: ['events', 'month', year, month],
queryFn: () => getEvents({ start, end }) as Promise<EventsResponse>,
staleTime: 5 * 60 * 1000,
})
}
export function useSources() {
return useQuery<SourcesResponse>({
queryKey: ['sources'],
queryFn: () => getSources() as Promise<SourcesResponse>,
staleTime: 10 * 60 * 1000, // 10 minutes
})
}
// Group events by date for calendar display
export function groupEventsByDate(events: EventListItem[]): Map<string, EventListItem[]> {
const grouped = new Map<string, EventListItem[]>()
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
}

13
src/index.ts Normal file
View File

@ -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'

188
src/lib/api.ts Normal file
View File

@ -0,0 +1,188 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'
async function fetcher<T>(url: string, options?: RequestInit): Promise<T> {
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/`)
}

197
src/lib/ifc.ts Normal file
View File

@ -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<number, string> = {
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'
}

83
src/lib/location.ts Normal file
View File

@ -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<string, UnifiedEvent[]> {
const grouped = new Map<string, UnifiedEvent[]>()
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
}

24
src/providers/index.tsx Normal file
View File

@ -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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}

284
src/types/index.ts Normal file
View File

@ -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, string> = {
[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<Record<TemporalGranularity, ViewType>> = {
[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, string> = {
[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, SpatialGranularity> = {
[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, number> = {
[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
}

20
tsconfig.json Normal file
View File

@ -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"]
}