Skip to content

Commit 3f46f17

Browse files
authored
Merge pull request #24 from YuWei-CH/Map-Dev
Map dev
2 parents b1456b6 + 4c34e33 commit 3f46f17

File tree

7 files changed

+573
-10
lines changed

7 files changed

+573
-10
lines changed

backend/app/main.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
reverse_geocode,
2525
rough_location_from_address,
2626
)
27-
from .models import Listing, Target, Workspace
27+
from .models import InterestingTarget, Listing, Target, Workspace
2828
from .openrouter import (
2929
extract_housing_post,
3030
OpenRouterConfigError,
@@ -39,6 +39,8 @@
3939
ListingSummaryOut,
4040
ListingUpsert,
4141
ReverseGeocodeOut,
42+
InterestingTargetOut,
43+
InterestingTargetUpsert,
4244
TargetOut,
4345
TargetUpsert,
4446
WorkspaceIssueOut,
@@ -497,6 +499,105 @@ def list_targets(db: DbDep, ws: WorkspaceDep) -> list[Target]:
497499
)
498500

499501

502+
@app.post("/api/interesting_targets", response_model=InterestingTargetOut)
503+
def upsert_interesting_target(
504+
payload: InterestingTargetUpsert,
505+
db: DbDep,
506+
ws: WorkspaceDep,
507+
) -> InterestingTarget:
508+
now = _utcnow()
509+
510+
lat = payload.lat
511+
lng = payload.lng
512+
address = payload.address.strip() if isinstance(payload.address, str) else None
513+
if address == "":
514+
address = None
515+
516+
if lat is None or lng is None:
517+
try:
518+
candidates = geocode_address(address or "", limit=1)
519+
except HTTPError as e:
520+
raise HTTPException(status_code=502, detail=str(e)) from e
521+
except GeocodingConfigError as e:
522+
raise HTTPException(status_code=500, detail=str(e)) from e
523+
except GeocodingProviderError as e:
524+
raise HTTPException(status_code=502, detail=str(e)) from e
525+
if not candidates:
526+
raise HTTPException(status_code=404, detail="Address not found")
527+
lat = candidates[0].lat
528+
lng = candidates[0].lng
529+
if address is None:
530+
address = candidates[0].display_name
531+
elif address is None:
532+
try:
533+
rev = reverse_geocode(lat, lng, zoom=14)
534+
address = rough_location_from_address(rev.address) or rev.display_name
535+
except HTTPError:
536+
address = None
537+
538+
target: InterestingTarget | None = None
539+
if payload.id:
540+
target = db.scalar(
541+
select(InterestingTarget).where(
542+
InterestingTarget.workspace_id == ws.id,
543+
InterestingTarget.id == payload.id,
544+
)
545+
)
546+
547+
if target:
548+
target.name = payload.name
549+
target.address = address
550+
target.lat = lat
551+
target.lng = lng
552+
target.updated_at = now
553+
db.add(target)
554+
db.commit()
555+
db.refresh(target)
556+
return target
557+
558+
create_kwargs = {
559+
"workspace_id": ws.id,
560+
"name": payload.name,
561+
"address": address,
562+
"lat": lat,
563+
"lng": lng,
564+
"updated_at": now,
565+
}
566+
if payload.id:
567+
create_kwargs["id"] = payload.id
568+
target = InterestingTarget(**create_kwargs)
569+
db.add(target)
570+
db.commit()
571+
db.refresh(target)
572+
return target
573+
574+
575+
@app.get("/api/interesting_targets", response_model=list[InterestingTargetOut])
576+
def list_interesting_targets(db: DbDep, ws: WorkspaceDep) -> list[InterestingTarget]:
577+
return list(
578+
db.scalars(
579+
select(InterestingTarget)
580+
.where(InterestingTarget.workspace_id == ws.id)
581+
.order_by(InterestingTarget.updated_at.desc())
582+
)
583+
)
584+
585+
586+
@app.delete("/api/interesting_targets/{target_id}")
587+
def delete_interesting_target(target_id: str, db: DbDep, ws: WorkspaceDep) -> dict[str, bool]:
588+
target = db.scalar(
589+
select(InterestingTarget).where(
590+
InterestingTarget.workspace_id == ws.id,
591+
InterestingTarget.id == target_id,
592+
)
593+
)
594+
if not target:
595+
raise HTTPException(status_code=404, detail="Interesting target not found")
596+
db.delete(target)
597+
db.commit()
598+
return {"deleted": True}
599+
600+
500601
@app.get("/api/compare", response_model=CompareResponse)
501602
def compare(
502603
db: DbDep,

backend/app/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,19 @@ class Target(Base):
7676
updated_at: Mapped[datetime] = mapped_column(
7777
DateTime(timezone=True), nullable=False, default=_utcnow
7878
)
79+
80+
81+
class InterestingTarget(Base):
82+
__tablename__ = "interesting_targets"
83+
84+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
85+
workspace_id: Mapped[str] = mapped_column(
86+
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
87+
)
88+
name: Mapped[str] = mapped_column(String(256), nullable=False)
89+
address: Mapped[str | None] = mapped_column(String(512))
90+
lat: Mapped[float] = mapped_column(Float, nullable=False)
91+
lng: Mapped[float] = mapped_column(Float, nullable=False)
92+
updated_at: Mapped[datetime] = mapped_column(
93+
DateTime(timezone=True), nullable=False, default=_utcnow
94+
)

backend/app/schemas.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,40 @@ class TargetOut(BaseModel):
109109
updated_at: datetime
110110

111111

112+
class InterestingTargetUpsert(BaseModel):
113+
id: str | None = None
114+
name: str = Field(min_length=1, max_length=256)
115+
address: str | None = Field(default=None, max_length=512)
116+
lat: float | None = None
117+
lng: float | None = None
118+
119+
@model_validator(mode="after")
120+
def _validate_location(self) -> "InterestingTargetUpsert":
121+
has_lat = self.lat is not None
122+
has_lng = self.lng is not None
123+
has_address = self.address is not None and self.address.strip() != ""
124+
has_coords = has_lat and has_lng
125+
126+
if has_lat != has_lng:
127+
raise ValueError("lat and lng must be provided together")
128+
if has_address and has_coords:
129+
raise ValueError("Provide either address, or both lat and lng (not both)")
130+
if not (has_address or has_coords):
131+
raise ValueError("Provide either address, or both lat and lng")
132+
return self
133+
134+
135+
class InterestingTargetOut(BaseModel):
136+
model_config = ConfigDict(from_attributes=True)
137+
138+
id: str
139+
name: str
140+
address: str | None
141+
lat: float
142+
lng: float
143+
updated_at: datetime
144+
145+
112146
class Metrics(BaseModel):
113147
distance_km: float | None = None
114148

frontend/src/App.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,47 @@
102102
color: #0f172a;
103103
}
104104

105+
.typeaheadHint {
106+
margin-top: 4px;
107+
font-size: 12px;
108+
color: #64748b;
109+
}
110+
111+
.typeaheadMenu {
112+
margin-top: 4px;
113+
max-height: 180px;
114+
overflow: auto;
115+
border: 1px solid #cbd5e1;
116+
border-radius: 8px;
117+
background: #ffffff;
118+
}
119+
120+
.typeaheadItem {
121+
width: 100%;
122+
border: 0;
123+
border-bottom: 1px solid #f1f5f9;
124+
background: #ffffff;
125+
padding: 8px 10px;
126+
text-align: left;
127+
font-size: 12px;
128+
color: #0f172a;
129+
cursor: pointer;
130+
}
131+
132+
.typeaheadItem:last-child {
133+
border-bottom: 0;
134+
}
135+
136+
.typeaheadItem:hover {
137+
background: #f8fafc;
138+
}
139+
140+
.typeaheadEmpty {
141+
margin-top: 4px;
142+
font-size: 12px;
143+
color: #64748b;
144+
}
145+
105146
.actions {
106147
display: flex;
107148
gap: 8px;

frontend/src/MapView.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useMemo, useRef, useState } from 'react'
22

3-
import type { CompareItem, Target } from './api'
3+
import type { CompareItem, InterestingTarget, Target } from './api'
44
import { DISABLE_GOOGLE_MAPS } from './config'
55
import { loadGoogleMaps } from './googleMaps'
66

@@ -17,6 +17,7 @@ type Props = {
1717
target: Target | null
1818
initialCenter: { lat: number; lng: number } | null
1919
items: CompareItem[]
20+
interestingTargets: InterestingTarget[]
2021
selectedListingId: string | null
2122
onSelectListingId: (id: string) => void
2223
isPickingTarget: boolean
@@ -56,7 +57,14 @@ const LAPTOP_PIN_SVG = `
5657
</svg>
5758
`.trim()
5859

59-
function markerIcon(kind: 'target' | 'listing', opts?: { selected?: boolean }) {
60+
const INTERESTING_PIN_SVG = `
61+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
62+
<circle cx="12" cy="12" r="10" fill="#16a34a" stroke="rgba(255,255,255,0.95)" stroke-width="2" />
63+
<path d="M12 6.8l1.6 3.2 3.5.5-2.5 2.4.6 3.4L12 14.7l-3.2 1.7.6-3.4-2.5-2.4 3.5-.5L12 6.8Z" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round" />
64+
</svg>
65+
`.trim()
66+
67+
function markerIcon(kind: 'target' | 'listing' | 'interesting', opts?: { selected?: boolean }) {
6068
const selected = opts?.selected ?? false
6169
if (kind === 'target') {
6270
return {
@@ -65,6 +73,13 @@ function markerIcon(kind: 'target' | 'listing', opts?: { selected?: boolean }) {
6573
anchor: new google.maps.Point(20, 20),
6674
}
6775
}
76+
if (kind === 'interesting') {
77+
return {
78+
url: svgUrl(INTERESTING_PIN_SVG),
79+
scaledSize: new google.maps.Size(30, 30),
80+
anchor: new google.maps.Point(15, 15),
81+
}
82+
}
6883
const size = selected ? 34 : 30
6984
return {
7085
url: svgUrl(selected ? HOUSE_PIN_SVG_SELECTED : HOUSE_PIN_SVG),
@@ -77,6 +92,7 @@ export default function MapView({
7792
target,
7893
initialCenter,
7994
items,
95+
interestingTargets,
8096
selectedListingId,
8197
onSelectListingId,
8298
isPickingTarget,
@@ -118,8 +134,14 @@ export default function MapView({
118134
lat: target.lat,
119135
},
120136
]
121-
return [...targetPoint, ...listingPoints]
122-
}, [items, target])
137+
const interestingPoints = interestingTargets.map((it) => ({
138+
id: it.id,
139+
kind: 'interesting' as const,
140+
lng: it.lng,
141+
lat: it.lat,
142+
}))
143+
return [...targetPoint, ...interestingPoints, ...listingPoints]
144+
}, [interestingTargets, items, target])
123145

124146
useEffect(() => {
125147
if (!containerRef.current) return
@@ -191,7 +213,7 @@ export default function MapView({
191213
position: { lat: p.lat, lng: p.lng },
192214
icon: markerIcon(p.kind, { selected: isSelected }),
193215
clickable: p.kind === 'listing' && !isPickingTarget,
194-
zIndex: p.kind === 'target' ? 3 : isSelected ? 2 : 1,
216+
zIndex: p.kind === 'target' ? 3 : p.kind === 'interesting' ? 2 : isSelected ? 2 : 1,
195217
})
196218
if (p.kind === 'listing' && !isPickingTarget) {
197219
marker.addListener('click', () => onSelectListingId(p.id))

frontend/src/api.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ export type Target = {
7070
updated_at: string
7171
}
7272

73+
export type InterestingTarget = {
74+
id: string
75+
name: string
76+
address: string | null
77+
lat: number
78+
lng: number
79+
updated_at: string
80+
}
81+
7382
export type CompareItem = {
7483
listing: Listing
7584
metrics: { distance_km: number | null }
@@ -147,6 +156,40 @@ export async function upsertTarget(payload: {
147156
return (await parseJsonOrThrow(res)) as Target
148157
}
149158

159+
export async function upsertInterestingTarget(payload: {
160+
id?: string
161+
name: string
162+
address?: string
163+
lat?: number
164+
lng?: number
165+
}): Promise<InterestingTarget> {
166+
const res = await fetchWithTimeout(apiUrl('/api/interesting_targets'), {
167+
method: 'POST',
168+
headers: { 'Content-Type': 'application/json', ...authHeaders() },
169+
body: JSON.stringify(payload),
170+
})
171+
return (await parseJsonOrThrow(res)) as InterestingTarget
172+
}
173+
174+
export async function listInterestingTargets(): Promise<InterestingTarget[]> {
175+
const res = await fetchWithTimeout(apiUrl('/api/interesting_targets'), {
176+
method: 'GET',
177+
headers: authHeaders(),
178+
})
179+
return (await parseJsonOrThrow(res)) as InterestingTarget[]
180+
}
181+
182+
export async function deleteInterestingTarget(id: string): Promise<void> {
183+
const res = await fetchWithTimeout(
184+
apiUrl(`/api/interesting_targets/${encodeURIComponent(id)}`),
185+
{
186+
method: 'DELETE',
187+
headers: authHeaders(),
188+
},
189+
)
190+
await parseJsonOrThrow(res)
191+
}
192+
150193
export async function fetchCompare(targetId?: string): Promise<CompareResponse> {
151194
const url = new URL(apiUrl('/api/compare'))
152195
if (targetId) url.searchParams.set('target_id', targetId)

0 commit comments

Comments
 (0)