Create new places

And find them in search
This commit is contained in:
Râu Cao 2026-01-27 11:58:24 +07:00
parent a10f87290a
commit 8c58a76030
Signed by: raucao
GPG Key ID: 37036C356E56CC51
12 changed files with 507 additions and 58 deletions

View File

@ -15,7 +15,6 @@ import Point from 'ol/geom/Point.js';
import Geolocation from 'ol/Geolocation.js';
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
import { apply } from 'ol-mapbox-style';
import { getDistance } from '../utils/geo';
export default class MapComponent extends Component {
@service osm;
@ -29,6 +28,8 @@ export default class MapComponent extends Component {
searchOverlayElement;
selectedPinOverlay;
selectedPinElement;
crosshairElement; // New crosshair
crosshairOverlay; // New crosshair overlay
setupMap = modifier((element) => {
if (this.mapInstance) return;
@ -140,6 +141,29 @@ export default class MapComponent extends Component {
});
this.mapInstance.addOverlay(this.selectedPinOverlay);
// Crosshair Overlay (for Creating New Place)
this.crosshairElement = document.createElement('div');
this.crosshairElement.className = 'map-crosshair';
// Use an SVG or simple CSS cross
this.crosshairElement.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
`;
// We attach it to the map control container OR keep it as an overlay centered on map center?
// Actually, a fixed center overlay is trickier in OpenLayers because Overlays move with the map.
// If we want it FIXED in the center of the VIEWPORT, it should be a Control or just an absolute HTML element on top of the map div.
// Adding it as a Control is cleaner.
// HOWEVER, the request says "cross hair drawn on the map... which should be removed when saving".
// A fixed element in the center of the screen is best for "choose location by dragging map".
// So let's append it to the map container directly via Glimmer template or JS.
// We'll append it to the map target element (this.element is the target).
element.appendChild(this.crosshairElement);
// Geolocation Pulse Overlay
this.locationOverlayElement = document.createElement('div');
this.locationOverlayElement.className = 'search-pulse blue';
@ -522,9 +546,128 @@ export default class MapComponent extends Component {
}
});
// Sync the creation mode (Crosshair)
syncCreationMode = modifier(() => {
if (!this.crosshairElement || !this.mapInstance) return;
if (this.mapUi.isCreating) {
this.crosshairElement.classList.add('visible');
// If we have initial coordinates from the route (e.g. reload or link),
// we need to pan the map so those coordinates are UNDER the crosshair.
const coords = this.mapUi.creationCoordinates;
if (coords && coords.lat && coords.lon) {
// We only animate if the map center isn't already "roughly" correct
// But actually, updateCreationCoordinates is called by handleMapMove too.
// We need to distinguish "initial set" vs "drag update".
// The Service doesn't distinguish, but if we are just entering mode,
// we can check if the current map center aligns.
// Better approach:
// We calculate where the map center *should* be to put the target coords
// under the crosshair.
const targetCoords = fromLonLat([coords.lon, coords.lat]);
this.animateToCrosshair(targetCoords);
}
} else {
this.crosshairElement.classList.remove('visible');
}
});
animateToCrosshair(targetCoords) {
if (!this.mapInstance || !this.crosshairElement) return;
// 1. Get current visual position of the crosshair
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
const crosshairRect = this.crosshairElement.getBoundingClientRect();
const crosshairPixelX =
crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
const crosshairPixelY =
crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
// 2. Get the center pixel of the map viewport
const size = this.mapInstance.getSize();
const mapCenterX = size[0] / 2;
const mapCenterY = size[1] / 2;
// 3. Calculate the offset (how far the crosshair is from the geometric center)
const offsetX = crosshairPixelX - mapCenterX;
const offsetY = crosshairPixelY - mapCenterY;
// 4. Calculate the new map center
// We want 'targetCoords' to be at [crosshairPixelX, crosshairPixelY].
// If we center the map on 'targetCoords', it will be at [mapCenterX, mapCenterY].
// So we need to shift the map center by the OPPOSITE of the offset.
// Wait.
// If crosshair is to the right (+X), we need to move the camera LEFT (-X) to bring the point there?
// Let's think in map units.
const view = this.mapInstance.getView();
const resolution = view.getResolution();
const offsetMapUnitsX = offsetX * resolution;
const offsetMapUnitsY = -offsetY * resolution; // Y is inverted in pixel vs map coords usually?
// In Web Mercator: Y increases North (Up).
// In Pixels: Y increases South (Down).
// So +PixelY (Down) = -MapY (South). Correct.
// If crosshair is at +100px (Right), we want the target to be there.
// If we center on target, it is at 0px.
// To make it appear at +100px, we must shift the camera center by -100px (Left).
// So CenterX_new = TargetX - offsetMapUnitsX.
const targetX = targetCoords[0];
const targetY = targetCoords[1];
const newCenterX = targetX - offsetMapUnitsX;
const newCenterY = targetY - offsetMapUnitsY;
// Only animate if the difference is significant (avoid micro-jitters/loops)
const currentCenter = view.getCenter();
const dist = Math.sqrt(
Math.pow(currentCenter[0] - newCenterX, 2) +
Math.pow(currentCenter[1] - newCenterY, 2)
);
// 1 meter is approx 1 unit in Mercator near equator, varies by latitude.
// Resolution at zoom 18 is approx 0.6m/pixel.
// Let's use a small pixel threshold.
if (dist > resolution * 5) {
view.animate({
center: [newCenterX, newCenterY],
duration: 800,
easing: (t) => t * (2 - t), // Ease-out
});
}
}
handleMapMove = async () => {
if (!this.mapInstance) return;
// If in creation mode, update the coordinates in the service AND the URL
if (this.mapUi.isCreating) {
// Calculate coordinates under the crosshair element
// We need the pixel position of the crosshair relative to the map viewport
// The crosshair is positioned via CSS, so we can use getBoundingClientRect
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
const crosshairRect = this.crosshairElement.getBoundingClientRect();
const centerX = crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
const centerY = crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
const coordinate = this.mapInstance.getCoordinateFromPixel([centerX, centerY]);
const center = toLonLat(coordinate);
const lat = parseFloat(center[1].toFixed(6));
const lon = parseFloat(center[0].toFixed(6));
this.mapUi.updateCreationCoordinates(lat, lon);
// Update URL without triggering a full refresh
// We use replaceWith to avoid cluttering history
this.router.replaceWith('place.new', { queryParams: { lat, lon } });
}
const size = this.mapInstance.getSize();
const extent = this.mapInstance.getView().calculateExtent(size);
const [minLon, minLat] = toLonLat([extent[0], extent[1]]);
@ -640,11 +783,12 @@ export default class MapComponent extends Component {
<template>
<div
class="map-container"
class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
{{this.setupMap}}
{{this.updateBookmarks}}
{{this.updateSelectedPin}}
{{this.syncPulse}}
{{this.syncCreationMode}}
></div>
</template>
}

View File

@ -3,19 +3,13 @@ import { fn } from '@ember/helper';
import { on } from '@ember/modifier';
import { humanizeOsmTag } from '../utils/format-text';
import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceDetails extends Component {
@tracked isEditing = false;
@tracked editTitle = '';
@tracked editDescription = '';
constructor() {
super(...arguments);
this.resetEditFields();
}
get place() {
return this.args.place || {};
@ -34,16 +28,9 @@ export default class PlaceDetails extends Component {
);
}
@action
resetEditFields() {
this.editTitle = this.name;
this.editDescription = this.place.description || '';
}
@action
startEditing() {
if (!this.place.createdAt) return; // Only allow editing saved places
this.resetEditFields();
this.isEditing = true;
}
@ -53,28 +40,16 @@ export default class PlaceDetails extends Component {
}
@action
async saveChanges(event) {
event.preventDefault();
async saveChanges(changes) {
if (this.args.onSave) {
await this.args.onSave({
...this.place,
title: this.editTitle,
description: this.editDescription,
...changes,
});
}
this.isEditing = false;
}
@action
updateTitle(e) {
this.editTitle = e.target.value;
}
@action
updateDescription(e) {
this.editDescription = e.target.value;
}
get type() {
const rawType =
this.tags.amenity ||
@ -171,32 +146,11 @@ export default class PlaceDetails extends Component {
<template>
<div class="place-details">
{{#if this.isEditing}}
<form class="edit-form" {{on "submit" this.saveChanges}}>
<div class="form-group">
<label for="edit-title">Title</label>
<input
id="edit-title"
type="text"
value={{this.editTitle}}
{{on "input" this.updateTitle}}
class="form-control"
/>
</div>
<div class="form-group">
<label for="edit-desc">Description</label>
<textarea
id="edit-desc"
value={{this.editDescription}}
{{on "input" this.updateDescription}}
class="form-control"
rows="3"
></textarea>
</div>
<div class="edit-actions">
<button type="submit" class="btn btn-blue">Save</button>
<button type="button" class="btn btn-outline" {{on "click" this.cancelEditing}}>Cancel</button>
</div>
</form>
<PlaceEditForm
@place={{this.place}}
@onSave={{this.saveChanges}}
@onCancel={{this.cancelEditing}}
/>
{{else}}
<h3>{{this.name}}</h3>
<p class="place-type">

View File

@ -0,0 +1,72 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceEditForm extends Component {
@tracked title = '';
@tracked description = '';
constructor() {
super(...arguments);
this.title = this.args.place?.title || '';
this.description = this.args.place?.description || '';
}
@action
handleSubmit(event) {
event.preventDefault();
if (this.args.onSave) {
this.args.onSave({
title: this.title,
description: this.description,
});
}
}
@action
updateTitle(e) {
this.title = e.target.value;
}
@action
updateDescription(e) {
this.description = e.target.value;
}
<template>
<form class="edit-form" {{on "submit" this.handleSubmit}}>
<div class="form-group">
<label for="edit-title">Title</label>
<input
id="edit-title"
type="text"
value={{this.title}}
{{on "input" this.updateTitle}}
class="form-control"
placeholder="Name of the place"
autofocus
/>
</div>
<div class="form-group">
<label for="edit-desc">Description</label>
<textarea
id="edit-desc"
value={{this.description}}
{{on "input" this.updateDescription}}
class="form-control"
rows="3"
placeholder="Add some details..."
></textarea>
</div>
<div class="edit-actions">
<button type="submit" class="btn btn-blue">Save</button>
<button
type="button"
class="btn btn-outline"
{{on "click" @onCancel}}
>Cancel</button>
</div>
</form>
</template>
}

View File

@ -10,6 +10,22 @@ import humanizeOsmTag from '../helpers/humanize-osm-tag';
export default class PlacesSidebar extends Component {
@service storage;
@service router;
@service mapUi;
@action
createNewPlace() {
const qp = this.router.currentRoute.queryParams;
const lat = qp.lat;
const lon = qp.lon;
if (lat && lon) {
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
} else {
// Fallback (shouldn't happen in search context)
this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } });
}
}
@action
selectPlace(place) {
@ -180,6 +196,7 @@ export default class PlacesSidebar extends Component {
place.osmTags.tourism
place.osmTags.leisure
place.osmTags.historic
"Point of Interest"
)}}</div>
</button>
</li>
@ -188,6 +205,15 @@ export default class PlacesSidebar extends Component {
{{else}}
<p class="empty-state">No places found nearby.</p>
{{/if}}
<button
type="button"
class="create-place-btn"
{{on "click" this.createNewPlace}}
>
<Icon @name="plus" @size={{18}} />
Create new place
</button>
{{/if}}
</div>
</div>

View File

@ -8,5 +8,6 @@ export default class Router extends EmberRouter {
Router.map(function () {
this.route('place', { path: '/place/:place_id' });
this.route('place.new', { path: '/place/new' });
this.route('search');
});

30
app/routes/place/new.js Normal file
View File

@ -0,0 +1,30 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PlaceNewRoute extends Route {
@service mapUi;
queryParams = {
lat: { refreshModel: true },
lon: { refreshModel: true },
};
model(params) {
return {
lat: parseFloat(params.lat),
lon: parseFloat(params.lon),
};
}
setupController(controller, model) {
super.setupController(controller, model);
if (model.lat && model.lon) {
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
}
this.mapUi.startCreating();
}
deactivate() {
this.mapUi.stopCreating();
}
}

View File

@ -28,6 +28,26 @@ export default class SearchRoute extends Route {
// Fetch POIs
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Get cached/saved places in search radius
const localMatches = this.storage.savedPlaces.filter((p) => {
const dist = getDistance(lat, lon, p.lat, p.lon);
return dist <= searchRadius;
});
// Add local matches to the list if they aren't already there
// We use osmId to deduplicate if possible
localMatches.forEach((local) => {
const exists = pois.find(
(poi) =>
(local.osmId && poi.osmId === local.osmId) ||
(poi.id && poi.id === local.id)
);
if (!exists) {
pois.push(local);
}
});
// Sort by distance from click
pois = pois
.map((p) => {

View File

@ -4,6 +4,8 @@ import { tracked } from '@glimmer/tracking';
export default class MapUiService extends Service {
@tracked selectedPlace = null;
@tracked isSearching = false;
@tracked isCreating = false;
@tracked creationCoordinates = null;
selectPlace(place) {
this.selectedPlace = place;
@ -15,9 +17,24 @@ export default class MapUiService extends Service {
startSearch() {
this.isSearching = true;
this.isCreating = false;
}
stopSearch() {
this.isSearching = false;
}
startCreating() {
this.isCreating = true;
this.isSearching = false;
}
stopCreating() {
this.isCreating = false;
this.creationCoordinates = null;
}
updateCreationCoordinates(lat, lon) {
this.creationCoordinates = { lat, lon };
}
}

View File

@ -216,29 +216,54 @@ export default class StorageService extends Service {
async storePlace(placeData) {
const savedPlace = await this.places.store(placeData);
// Only append if not already there (handlePlaceChange might also fire)
// Optimistic Update: Global List
if (!this.savedPlaces.some((p) => p.id === savedPlace.id)) {
this.savedPlaces = [...this.savedPlaces, savedPlace];
} else {
// Update if exists
this.savedPlaces = this.savedPlaces.map((p) =>
p.id === savedPlace.id ? savedPlace : p
);
}
// Optimistic Update: Map View (same logic as Global List)
if (!this.placesInView.some((p) => p.id === savedPlace.id)) {
this.placesInView = [...this.placesInView, savedPlace];
} else {
this.placesInView = this.placesInView.map((p) =>
p.id === savedPlace.id ? savedPlace : p
);
}
return savedPlace;
}
async updatePlace(placeData) {
const savedPlace = await this.places.store(placeData);
// Update local list
// Optimistic Update: Global List
const index = this.savedPlaces.findIndex((p) => p.id === savedPlace.id);
if (index !== -1) {
const newPlaces = [...this.savedPlaces];
newPlaces[index] = savedPlace;
this.savedPlaces = newPlaces;
}
// Update Map View
this.placesInView = this.placesInView.map((p) =>
p.id === savedPlace.id ? savedPlace : p
);
return savedPlace;
}
async removePlace(place) {
await this.places.remove(place.id, place.geohash);
// Update both lists
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id);
this.placesInView = this.placesInView.filter((p) => p.id !== place.id);
}
@action

View File

@ -649,6 +649,82 @@ span.icon {
}
}
/* Map Crosshair for "Create Place" mode */
.map-crosshair {
position: absolute;
/* Default Center */
top: 50%;
left: 50%;
width: 24px;
height: 24px;
transform: translate(-50%, -50%);
color: #333;
pointer-events: none;
z-index: 2000;
display: none;
transition:
top 0.3s ease,
left 0.3s ease;
}
.map-crosshair.visible {
display: block;
}
/* Sidebar is open (Desktop: Left 300px) */
/* We want to center in the remaining space (width - 300px) */
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
/* So shift left by 150px from center */
.map-container.sidebar-open .map-crosshair {
left: calc(50% + 150px);
}
@media (width <= 768px) {
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
/* Center Y = (height/2) / 2 = height/4 = 25% */
.map-container.sidebar-open .map-crosshair {
left: 50%; /* Reset desktop shift */
top: 25%;
}
}
/* Helper Text */
.helper-text {
background: #eef4fc;
color: #1a5c9b;
padding: 0.75rem;
border-radius: 4px;
font-size: 0.9rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Create Place Button */
.create-place-btn {
width: 100%;
padding: 1rem;
margin-top: 1rem;
background: white;
border: 1px dashed #ccc;
border-radius: 8px;
color: #666;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
}
.create-place-btn:hover {
background: #f8f9fa;
border-color: #999;
color: #333;
}
@media (width <= 768px) {
.sidebar {
width: 100%;

View File

@ -21,6 +21,7 @@ export default class ApplicationComponent extends Component {
// This helps the map know if it should shift the center or adjust view.
return (
this.router.currentRouteName === 'place' ||
this.router.currentRouteName === 'place.new' ||
this.router.currentRouteName === 'search'
);
}

View File

@ -0,0 +1,83 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import PlaceEditForm from '#components/place-edit-form';
import Icon from '#components/icon';
export default class PlaceNewTemplate extends Component {
@service router;
@service storage;
@service mapUi;
get initialPlace() {
return {
title: '',
description: '',
};
}
@action
close() {
this.router.transitionTo('index');
}
@action
async savePlace(changes) {
try {
// Use coordinates from Map UI (which tracks the crosshair center)
// Fallback to URL params if map state isn't ready
const center = this.mapUi.creationCoordinates || {
lat: this.args.model.lat,
lon: this.args.model.lon,
};
const lat = parseFloat(center.lat.toFixed(6));
const lon = parseFloat(center.lon.toFixed(6));
const placeData = {
title: changes.title || 'Untitled Place',
description: changes.description,
lat: lat,
lon: lon,
tags: [],
osmTags: {},
};
const savedPlace = await this.storage.storePlace(placeData);
console.log('Created private place:', savedPlace.title);
// Transition to the new place
this.router.replaceWith('place', savedPlace);
} catch (e) {
console.error('Failed to create place:', e);
alert('Failed to create place: ' + e.message);
}
}
<template>
<div class="sidebar">
<div class="sidebar-header">
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
New Place</h2>
<button type="button" class="close-btn" {{on "click" this.close}}><Icon
@name="x"
@size={{20}}
@color="#333"
/></button>
</div>
<div class="sidebar-content">
<p class="helper-text">
Drag the map to position the crosshair.
</p>
<PlaceEditForm
@place={{this.initialPlace}}
@onSave={{this.savePlace}}
@onCancel={{this.close}}
/>
</div>
</div>
</template>
}