Skip to content

Commit

Permalink
improve cities select; add mixed time view
Browse files Browse the repository at this point in the history
  • Loading branch information
vladkens committed Jan 26, 2024
1 parent 9d8d3d0 commit ade772a
Show file tree
Hide file tree
Showing 12 changed files with 322 additions and 140 deletions.
158 changes: 158 additions & 0 deletions geonames.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import csv
import io
import json
import typing
import zipfile
from collections import defaultdict
from dataclasses import dataclass, fields

import httpx


@dataclass
class TypedTrait:
def __unbound(self, t, v):
try:
return t(v)
except ValueError:
return t() # default value for type

def __post_init__(self):
for f in fields(self.__class__):
v = getattr(self, f.name)
if typing.get_origin(f.type) is list:
g = typing.get_args(f.type)[0]
setattr(self, f.name, [self.__unbound(g, x) for x in v.split(",")])
else:
setattr(self, f.name, self.__unbound(f.type, v))

def __getitem__(self, key):
return getattr(self, key)


@dataclass
class City(TypedTrait):
# https://download.geonames.org/export/dump/readme.txt
geonameid: int
name: str
asciiname: str
alternatenames: list[str]
latitude: float
longitude: float
feature_class: str
feature_code: str
country_code: str
cc2: str
admin1_code: str
admin2_code: str
admin3_code: str
admin4_code: str
population: int
elevation: int
dem: int
timezone: str
modification_date: str


@dataclass
class Country(TypedTrait):
# https://download.geonames.org/export/dump/countryInfo.txt
iso: str
iso3: str
iso_numeric: int
fips: str
country: str
capital: str
area: int
population: int
continent: str
tld: str
currency_code: str
currency_name: str
phone: str
postal_code_format: str
postal_code_regex: str
languages: list[str]
geonameid: int
neighbours: list[str]
equivalent_fips_code: str


def get_cities():
rep = httpx.get("https://download.geonames.org/export/dump/cities15000.zip")
rep.raise_for_status()

with zipfile.ZipFile(io.BytesIO(rep.content)) as zip:
assert len(zip.namelist()) == 1
data = zip.read(zip.namelist()[0]).decode("utf-8").splitlines()
rows = csv.reader(data, delimiter="\t")
return [City(*row) for row in rows]


def get_countries():
rep = httpx.get("https://download.geonames.org/export/dump/countryInfo.txt")
rep.raise_for_status()

data = rep.text.split("EquivalentFipsCode")[1].splitlines()
data = [x for x in data if len(x)]
rows = csv.reader(data, delimiter="\t")
return [Country(*row) for row in rows]


MIN_POPULATION = 75_000


def main():
cities = get_cities()
# cities = [x for x in cities if x.population >= 100_000]
cities = sorted(cities, key=lambda x: x.population, reverse=True)

timezones = sorted([x.timezone for x in cities])
timezones_idx = {x: i for i, x in enumerate(timezones)}

countries = sorted([[x.iso, x.country] for x in get_countries()])
countries_idx = {x[0]: i for i, x in enumerate(countries)}

features_count = defaultdict(int)
for x in cities:
if x.population < MIN_POPULATION:
continue

features_count[x.feature_code] += 1

features_count = sorted(features_count.items(), key=lambda x: x[1], reverse=True)
for k, v in features_count:
print(k, v)

# https://www.geonames.org/export/codes.html
features_skip = set(["PPLA5", "PPLA4", "PPLA3"])
cities = [x for x in cities if x.feature_code not in features_skip]

cities_by_country: dict[str, list[City]] = defaultdict(list)
for x in cities:
cities_by_country[x.country_code].append(x)

places = []
for x in countries:
items = cities_by_country.get(x[0], [])
# always first 3 cities + other big cities
items = items[:3] + [x for x in items[3:] if x.population >= MIN_POPULATION]
if not len(items):
# print(f"Missing cities for {x[0]}")
continue

for x in items:
tz_idx, ct_idx = timezones_idx[x.timezone], countries_idx[x.country_code]
r = [x.geonameid, x.name, tz_idx, ct_idx]
places.append(r)

print(f"{len(places)} of {len(cities)}")

with open("src/utils/geonames.json", "w") as fp:
data = dict(timezones=timezones, countries=countries, cities=places)
# json.dump(data, fp, indent=2, ensure_ascii=False)
json.dump(data, fp)


if __name__ == "__main__":
main()
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
},
"dependencies": {
"@heroicons/react": "^2.1.1",
"@vvo/tzdb": "^6.125.0",
"array-utils-ts": "^0.1.2",
"clsx": "^2.1.0",
"fuse.js": "^7.0.0",
Expand All @@ -23,13 +22,11 @@
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.17",
"countries-and-timezones": "^3.6.0",
"postcss": "^8.4.33",
"prettier": "^3.2.4",
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.1",
"timezones.json": "^1.7.1",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-bundlesize": "^0.0.7"
Expand Down
15 changes: 6 additions & 9 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FC, useState } from "react"
import { SelectPlace } from "./components/SelectPlace"
import { Timeline } from "./components/Timeline"
import { TzListState, TzModeState } from "./state"
import { getPlaceByTzName } from "./utils/places"
import { getGeoNameById } from "./utils/geonames"

const ChangeTheme: FC = () => {
const [dark, setDark] = useState(() => localStorage.getItem("dark") === "true")
Expand All @@ -33,7 +33,7 @@ const ChangeTimeView: FC = () => {
const buttons: { value: typeof value; text: string; cls: string }[] = [
{ value: "12" as const, text: "am\npm", cls: "text-[10px]" },
{ value: "24" as const, text: "24", cls: "text-[13px]" },
// { value: "MX" as const, text: "MX", cls: "text-[12px]" },
{ value: "MX" as const, text: "MX", cls: "text-[12px]" },
]

return (
Expand Down Expand Up @@ -74,7 +74,7 @@ const Main: FC = () => {
const places = filterNullable(
tzs.map((tz) => {
try {
return getPlaceByTzName(tz)
return getGeoNameById(tz)
} catch (e) {
return null
}
Expand All @@ -85,16 +85,13 @@ const Main: FC = () => {
<main className="flex flex-col rounded-lg border bg-card text-card-content shadow">
<div className="flex items-center gap-2.5 bg-body/30 px-4 py-2.5">
<div className="w-full max-w-[228px]">
<SelectPlace
values={places}
onChange={(place) => setTzs((old) => [...old, place.tzName])}
/>
<SelectPlace values={places} onChange={(place) => setTzs((old) => [...old, place.uid])} />
</div>
</div>

<div className="flex flex-col py-2.5">
{places.map((x, idx) => (
<Timeline key={`${idx}-${x.tzAbbr}`} place={x} />
{places.map((x) => (
<Timeline key={x.uid} place={x} />
))}
</div>
</main>
Expand Down
50 changes: 26 additions & 24 deletions src/components/SelectPlace.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,49 @@
import { TimeZone } from "@vvo/tzdb"
import clsx from "clsx"
import Fuse from "fuse.js"
import { FC, useEffect, useState } from "react"
import { Place, TimeZones, tzToPlace } from "../utils/places"
import { GeoName, getGeoNames } from "../utils/geonames"

type SelectPlaceProps = {
values: Place[]
onChange: (place: Place) => void
values: GeoName[]
onChange: (place: GeoName) => void
}

export const SelectPlace: FC<SelectPlaceProps> = ({ values, onChange }) => {
const [value, setValue] = useState("")
const [options, setOptions] = useState<GeoName[]>([])
const [cursorIndex, setCursorIndex] = useState<number>(0)

const fuse = new Fuse(Object.values(TimeZones), {
threshold: 0.1,
keys: ["name", "mainCities", "countryName"],
})

const options = (value.length > 0 ? fuse.search(value).map((x) => x.item) : [])
.filter((x) => !values.find((y) => y.tzName === x.name))
.slice(0, 5)

const handleSelect = (tz: TimeZone) => {
onChange(tzToPlace(tz))
const handleSelect = (tz: GeoName) => {
onChange(tz)
setValue("")
}

const [cursorIndex, setCursorIndex] = useState<number>(0)

useEffect(() => {
setCursorIndex(0)

const fuse = new Fuse(getGeoNames(), { threshold: 0.2, keys: ["country", "city"] })
const options = (value.length > 0 ? fuse.search(value).map((x) => x.item) : [])
.filter((x) => !values.find((y) => y.uid === x.uid))
.slice(0, 7)

setOptions(options)
}, [value])

useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.code === "Enter" || e.code === "Space") {
if (e.code === "Enter") {
e.preventDefault()
const item = options[cursorIndex]
if (item) handleSelect(item)
return
}

if (e.code === "Escape") {
e.preventDefault()
setValue("")
return
}

if (e.code === "ArrowDown" || e.code === "ArrowUp") {
e.preventDefault()
const next = cursorIndex + (e.code === "ArrowDown" ? 1 : -1)
Expand All @@ -67,23 +70,22 @@ export const SelectPlace: FC<SelectPlaceProps> = ({ values, onChange }) => {
)}
/>
{options.length > 0 && (
<div className="absolute z-[100] mt-0.5 w-full rounded-md border bg-card">
<div className="absolute z-[100] mt-0.5 w-[320px] rounded-md border bg-card">
{options.map((x, idx) => (
<button
key={`${idx}-${x.abbreviation}`}
key={x.uid}
onClick={() => handleSelect(x)}
onMouseOver={() => setCursorIndex(idx)}
className={clsx(
"flex w-full items-center justify-between gap-2.5",
"h-[32px] px-1.5 py-1",
"text-sm leading-none",
"h-[32px] px-1.5 py-1 text-sm leading-none",
idx === cursorIndex && "bg-card-content/30",
)}
>
<div className="line-clamp-1 grow text-left">
{x.mainCities[0].trim()}, {x.countryName}
{x.city}, {x.country}
</div>
<div className="shrink-0">{x.abbreviation}</div>
{/* <div className="shrink-0">{x.abbreviation}</div> */}
</button>
))}
</div>
Expand Down
Loading

0 comments on commit ade772a

Please sign in to comment.