diff --git a/packages/catalog-realm/Spec/48b5382b-1f1e-4fdc-9711-98a1809b07fc.json b/packages/catalog-realm/Spec/48b5382b-1f1e-4fdc-9711-98a1809b07fc.json deleted file mode 100644 index 46f6ea1efef..00000000000 --- a/packages/catalog-realm/Spec/48b5382b-1f1e-4fdc-9711-98a1809b07fc.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "readMe": null, - "ref": { - "module": "../fields/geo-search-point", - "name": "default" - }, - "specType": "card", - "containedExamples": [], - "cardTitle": "GeoSearchPointField", - "cardDescription": null, - "cardInfo": { - "name": null, - "summary": null, - "cardThumbnailURL": null, - "notes": null - } - }, - "relationships": { - "linkedExamples": { - "links": { - "self": null - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/packages/catalog-realm/Spec/5153f123-f7a9-4c1d-8979-615191127e09.json b/packages/catalog-realm/Spec/5153f123-f7a9-4c1d-8979-615191127e09.json deleted file mode 100644 index 7cbb47a11f1..00000000000 --- a/packages/catalog-realm/Spec/5153f123-f7a9-4c1d-8979-615191127e09.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "data": { - "meta": { - "fields": { - "containedExamples": [ - { - "adoptsFrom": { - "module": "../fields/geo-search-point", - "name": "default" - } - } - ] - }, - "adoptsFrom": { - "name": "Spec", - "module": "https://cardstack.com/base/spec" - } - }, - "type": "card", - "attributes": { - "ref": { - "name": "default", - "module": "../fields/geo-search-point" - }, - "cardTitle": "GeoSearchPointField", - "readMe": "# GeoSearchPointField\n\nA field type that extends GeoPointField to include address search functionality with geocoding. This field allows users to search for locations by address and automatically converts them to geographic coordinate.\n\n## Usage\n\n### Basic Implementation\n\n```gts\nimport GeoSearchPointField from '../fields/geo-search-point';\nimport { CardDef, field, contains } from 'https://cardstack.com/base/card-api';\n\nexport class MyCard extends CardDef {\n @field location = contains(GeoSearchPointField);\n}\n```\n\n### Field Properties\n\nInherits all properties from GeoPointField:\n- **lat**: Latitude coordinate (number)\n- **lon**: Longitude coordinate (number) \n\nAdds:\n- **searchKey**: The address or location name used for searching (string)\n\n### Display Formats\n\n- **Atom**: Compact view with map icon and address name\n- **Embedded**: Full view with address, coordinate, and interactive map\n- **Edit**: Address search input with live geocoding and marker color picker\n\n### Example Data\n\n```json\n{\n \"searchKey\": \"Glo Damansara\",\n \"lat\": 3.1336268,\n \"lon\": 101.6299203\n}\n```", - "cardInfo": { - "notes": null, - "name": null, - "summary": null, - "cardThumbnailURL": null - }, - "specType": "field", - "cardDescription": "Spec of GeoSearchPointField", - "containedExamples": [ - { - "searchKey": "Glo Damansara", - "lat": 3.1336268, - "lon": 101.6299203 - } - ] - }, - "relationships": { - "cardInfo.theme": { - "links": { - "self": null - } - }, - "linkedExamples": { - "links": { - "self": null - } - } - } - } -} diff --git a/packages/catalog-realm/Spec/9e538a36-264e-4b5d-b031-6b2c5298af95.json b/packages/catalog-realm/Spec/9e538a36-264e-4b5d-b031-6b2c5298af95.json deleted file mode 100644 index 21ef67737f5..00000000000 --- a/packages/catalog-realm/Spec/9e538a36-264e-4b5d-b031-6b2c5298af95.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "data": { - "meta": { - "fields": { - "containedExamples": [ - { - "adoptsFrom": { - "module": "../fields/geo-point", - "name": "default" - } - } - ] - }, - "adoptsFrom": { - "name": "Spec", - "module": "https://cardstack.com/base/spec" - } - }, - "type": "card", - "attributes": { - "ref": { - "name": "default", - "module": "../fields/geo-point" - }, - "cardTitle": "GeoPointField", - "readMe": "# GeoPointField\n\nA field type for storing and displaying geographic coordinates (latitude and longitude).\n\n## Usage\n\n### Basic Implementation\n\n```gts\nimport GeoPointField from '../fields/geo-point';\nimport { CardDef, field, contains } from 'https://cardstack.com/base/card-api';\n\nexport class MyCard extends CardDef {\n @field location = contains(GeoPointField);\n}\n```\n\n### Field Properties\n\n- **lat**: Latitude coordinate (number)\n- **lon**: Longitude coordinate (number)\n\n### Display Formats\n\n- **Atom**: Compact view with map icon and coordinates\n- **Embedded**: Full view with interactive map and coordinate editing\n- **Edit**: Form fields for latitude and longitude input\n\n### Example Data\n\n```json\n{\n \"lat\": 37.7746,\n \"lon\": -122.4068\n}\n```", - "cardInfo": { - "notes": null, - "name": null, - "summary": null, - "cardThumbnailURL": null - }, - "specType": "field", - "cardDescription": "Spec of GeoPointField", - "containedExamples": [ - { - "lat": 3.1336268, - "lon": 101.6299203 - } - ] - }, - "relationships": { - "cardInfo.theme": { - "links": { - "self": null - } - }, - "linkedExamples": { - "links": { - "self": null - } - } - } - } -} diff --git a/packages/catalog-realm/Spec/geo-point.json b/packages/catalog-realm/Spec/geo-point.json deleted file mode 100644 index a106898d995..00000000000 --- a/packages/catalog-realm/Spec/geo-point.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "data": { - "type": "card", - "attributes": { - "readMe": null, - "ref": { - "module": "../fields/geo-point", - "name": "default" - }, - "specType": "field", - "containedExamples": [], - "cardTitle": "GeoPointField", - "cardDescription": null, - "cardInfo": { - "name": null, - "summary": null, - "cardThumbnailURL": null, - "notes": null - } - }, - "relationships": { - "linkedExamples": { - "links": { - "self": null - } - } - }, - "meta": { - "adoptsFrom": { - "module": "https://cardstack.com/base/spec", - "name": "Spec" - } - } - } -} diff --git a/packages/catalog-realm/components/map-render.gts b/packages/catalog-realm/components/map-render.gts index 803ea84e50c..8536ede4cac 100644 --- a/packages/catalog-realm/components/map-render.gts +++ b/packages/catalog-realm/components/map-render.gts @@ -88,7 +88,6 @@ export class MapRender extends GlimmerComponent { margin: 0; width: 100%; height: 100%; - min-height: 300px; position: relative; display: flex; align-items: center; @@ -198,7 +197,10 @@ class LeafletLayerState implements LeafletLayerStateInterface { const color = i === 0 ? '#22c55e' : i === coords.length - 1 ? '#ef4444' : '#3b82f6'; const marker = createMarker(c, color); - if (c.address) marker.bindPopup(c.address); + const trimmedAddress = c.address?.trim() || undefined; + const popupContent = + trimmedAddress ?? `${c.lat.toFixed(6)}, ${c.lng.toFixed(6)}`; + marker.bindPopup(popupContent); return marker; }); } @@ -247,7 +249,7 @@ class LeafletLayerState implements LeafletLayerStateInterface { if (coords.length === 1) { // single point → just flyTo - this.map.flyTo([coords[0].lat, coords[0].lng], 15, { + this.map.flyTo([coords[0].lat, coords[0].lng], 13, { animate: true, duration: 1.2, }); @@ -348,7 +350,9 @@ export default class LeafletModifier extends Modifier ) { if (!this.element) return; - this.map = L.map(this.element).setView([0, 0], 13); + const center = [20, 0]; + const zoom = 2; + this.map = L.map(this.element).setView(center, zoom); L.tileLayer( mapConfig?.tileserverUrl || diff --git a/packages/catalog-realm/field-spec/GeoPointFieldSpec/d1f2c3b4-a5e6-4f7a-8b9c-0d1e2f3a4b5c.json b/packages/catalog-realm/field-spec/GeoPointFieldSpec/d1f2c3b4-a5e6-4f7a-8b9c-0d1e2f3a4b5c.json new file mode 100644 index 00000000000..3b13775ca6a --- /dev/null +++ b/packages/catalog-realm/field-spec/GeoPointFieldSpec/d1f2c3b4-a5e6-4f7a-8b9c-0d1e2f3a4b5c.json @@ -0,0 +1,72 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "GeoPointFieldSpec", + "module": "../geo-point-spec" + } + }, + "type": "card", + "attributes": { + "ref": { + "name": "default", + "module": "../../fields/geo-point" + }, + "basic": { + "lat": 40.7127281, + "lon": -74.0060152 + }, + "readMe": null, + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "combined": { + "lat": 51.5074456, + "lon": -0.1277653 + }, + "specType": "field", + "cardTitle": "Geo Point Field", + "mapPicker": { + "lat": 54.51384095408507, + "lon": 30.44159057801324 + }, + "cardDescription": "Spec card that renders GeoPointField examples.", + "containedExamples": [], + "withQuickLocations": { + "lat": 40.7127281, + "lon": -74.0060152 + }, + "mapPickerWithAddons": { + "lat": 44.83685807826857, + "lon": 11.61730322820671 + }, + "withCurrentLocation": { + "lat": 3.1685246346835263, + "lon": 101.53336335143155 + }, + "mapPickerWithQuickLocations": { + "lat": 51.5074456, + "lon": -0.1277653 + }, + "mapPickerWithCurrentLocation": { + "lat": 3.1685528122228006, + "lon": 101.53336585084402 + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "linkedExamples": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/field-spec/GeoSearchPointFieldSpec/3e4f5a6b-7c8d-9e0f-1a2b-3c4d5e6f7a8b.json b/packages/catalog-realm/field-spec/GeoSearchPointFieldSpec/3e4f5a6b-7c8d-9e0f-1a2b-3c4d5e6f7a8b.json new file mode 100644 index 00000000000..2db9ba2f8e8 --- /dev/null +++ b/packages/catalog-realm/field-spec/GeoSearchPointFieldSpec/3e4f5a6b-7c8d-9e0f-1a2b-3c4d5e6f7a8b.json @@ -0,0 +1,60 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "GeoSearchPointFieldSpec", + "module": "../geo-search-point-spec" + } + }, + "type": "card", + "attributes": { + "ref": { + "name": "default", + "module": "../../fields/geo-search-point" + }, + "basic": { + "lat": 3.1336269, + "lon": 101.6299203, + "searchKey": "Glo damansara" + }, + "readMe": null, + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "combined": { + "lat": 3.1516964, + "lon": 101.6942371, + "searchKey": "Kuala Lumpur, 50100, Malaysia" + }, + "specType": "field", + "cardTitle": "Geo Search Point Field", + "withTopResults": { + "lat": 3.1414907, + "lon": 101.7182597, + "searchKey": "Tun Razak Exchange (TRX), Pudu, Kuala Lumpur, 55188, Malaysia" + }, + "cardDescription": "Spec card that renders GeoSearchPointField examples.", + "containedExamples": [], + "withoutRecentSearches": { + "lat": 48.8534951, + "lon": 2.3483915, + "searchKey": "Paris, Ile-de-France, Metropolitan France, France" + } + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "linkedExamples": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/field-spec/geo-point-spec.gts b/packages/catalog-realm/field-spec/geo-point-spec.gts new file mode 100644 index 00000000000..c1e2060fb4c --- /dev/null +++ b/packages/catalog-realm/field-spec/geo-point-spec.gts @@ -0,0 +1,335 @@ +import { + Spec, + SpecHeader, + SpecReadmeSection, + ExamplesWithInteractive, + SpecModuleSection, +} from 'https://cardstack.com/base/spec'; +import { + field, + contains, + Component, +} from 'https://cardstack.com/base/card-api'; +import GeoPointField from '../fields/geo-point'; +import CodeSnippet from '../components/code-snippet'; + +// 1. Basic standard (no config needed) +const basicFieldCode = `@field basic = contains(GeoPointField);`; + +// 2. With current location tracker +const withCurrentLocationFieldCode = `@field withCurrentLocation = contains(GeoPointField, { + configuration: { + options: { + showCurrentLocation: true, + }, + }, +});`; + +// 3. With quick locations +const withQuickLocationsFieldCode = `@field withQuickLocations = contains(GeoPointField, { + configuration: { + options: { + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, +});`; + +// 4. Combined: current location + quick locations +const combinedFieldCode = `@field combined = contains(GeoPointField, { + configuration: { + options: { + showCurrentLocation: true, + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, +});`; + +// 5. Map picker variant (no options) +const mapPickerFieldCode = `@field mapPicker = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + }, +});`; + +// 6. Map picker with showCurrentLocation (MapPickerOptions) +const mapPickerWithCurrentLocationFieldCode = `@field mapPickerWithCurrentLocation = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + mapHeight: '300px', + showCurrentLocation: true, + }, + }, +});`; + +// 7. Map picker with quickLocations (MapPickerOptions) +const mapPickerWithQuickLocationsFieldCode = `@field mapPickerWithQuickLocations = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + mapHeight: '300px', + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, +});`; + +// 8. Map picker with both addons + map options (MapPickerOptions) +const mapPickerWithAddonsFieldCode = `@field mapPickerWithAddons = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + tileserverUrl: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + mapHeight: '300px', + showCurrentLocation: true, + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, +});`; + +class GeoPointFieldSpecIsolated extends Component { + +} + +class GeoPointFieldSpecEdit extends Component { + +} + +export class GeoPointFieldSpec extends Spec { + static displayName = 'Geo Point Field Spec'; + + // 1. Basic standard (no config) + @field basic = contains(GeoPointField); + + // 2. With current location tracker + @field withCurrentLocation = contains(GeoPointField, { + configuration: { + options: { + showCurrentLocation: true, + }, + }, + }); + + // 3. With quick locations + @field withQuickLocations = contains(GeoPointField, { + configuration: { + options: { + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, + }); + + // 4. Combined: current location + quick locations + @field combined = contains(GeoPointField, { + configuration: { + options: { + showCurrentLocation: true, + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, + }); + + // 5. Map picker variant (no options) + @field mapPicker = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + }, + }); + + // 6. Map picker with showCurrentLocation (using MapPickerOptions) + @field mapPickerWithCurrentLocation = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + mapHeight: '300px', + showCurrentLocation: true, + }, + }, + }); + + // 7. Map picker with quickLocations (using MapPickerOptions) + @field mapPickerWithQuickLocations = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + mapHeight: '300px', + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, + }); + + // 8. Map picker with both addons + map options (using MapPickerOptions) + @field mapPickerWithAddons = contains(GeoPointField, { + configuration: { + variant: 'map-picker', + options: { + tileserverUrl: + 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + mapHeight: '300px', + showCurrentLocation: true, + quickLocations: ['London', 'Paris', 'Tokyo', 'New York'], + }, + }, + }); + + static isolated = + GeoPointFieldSpecIsolated as unknown as typeof Spec.isolated; + static edit = GeoPointFieldSpecEdit as unknown as typeof Spec.edit; +} diff --git a/packages/catalog-realm/field-spec/geo-search-point-spec.gts b/packages/catalog-realm/field-spec/geo-search-point-spec.gts new file mode 100644 index 00000000000..898ec4761c1 --- /dev/null +++ b/packages/catalog-realm/field-spec/geo-search-point-spec.gts @@ -0,0 +1,224 @@ +import { + Spec, + SpecHeader, + SpecReadmeSection, + ExamplesWithInteractive, + SpecModuleSection, +} from 'https://cardstack.com/base/spec'; +import { + field, + contains, + Component, +} from 'https://cardstack.com/base/card-api'; +import GeoSearchPointField from '../fields/geo-search-point'; +import CodeSnippet from '../components/code-snippet'; + +// 1. Basic (no config) +const basicFieldCode = `@field basic = contains(GeoSearchPointField);`; + +// 2. With top search results +const withTopResultsCode = `@field withTopResults = contains(GeoSearchPointField, { + configuration: { + options: { + showTopSearchResults: true, + topSearchResultsLimit: 5, + }, + }, +});`; + +// 3. Top results without recent searches +const withoutRecentSearchesCode = `@field withoutRecentSearches = contains(GeoSearchPointField, { + configuration: { + options: { + showTopSearchResults: true, + topSearchResultsLimit: 5, + showRecentSearches: false, + }, + }, +});`; + +// 4. Combined: all features +const combinedCode = `@field combined = contains(GeoSearchPointField, { + configuration: { + options: { + placeholder: 'Start typing an address...', + showTopSearchResults: true, + topSearchResultsLimit: 5, + recentSearchesLimit: 5, + }, + }, +});`; + +class GeoSearchPointFieldSpecIsolated extends Component< + typeof GeoSearchPointFieldSpec +> { + +} + +class GeoSearchPointFieldSpecEdit extends Component< + typeof GeoSearchPointFieldSpec +> { + +} + +export class GeoSearchPointFieldSpec extends Spec { + static displayName = 'Geo Search Point Field Spec'; + + // 1. Basic (no config) + @field basic = contains(GeoSearchPointField); + + // 2. With top search results + @field withTopResults = contains(GeoSearchPointField, { + configuration: { + options: { + showTopSearchResults: true, + topSearchResultsLimit: 5, + }, + }, + }); + + // 3. Top results without recent searches + @field withoutRecentSearches = contains(GeoSearchPointField, { + configuration: { + options: { + showTopSearchResults: true, + topSearchResultsLimit: 5, + showRecentSearches: false, + }, + }, + }); + + // 4. Combined: all features + @field combined = contains(GeoSearchPointField, { + configuration: { + options: { + placeholder: 'Start typing an address...', + showTopSearchResults: true, + topSearchResultsLimit: 5, + recentSearchesLimit: 5, + }, + }, + }); + + static isolated = + GeoSearchPointFieldSpecIsolated as unknown as typeof Spec.isolated; + static edit = GeoSearchPointFieldSpecEdit as unknown as typeof Spec.edit; +} diff --git a/packages/catalog-realm/fields-preview/GeoPointPreview/ef3d6b85-cc8b-46fb-9447-a329d2ab270c.json b/packages/catalog-realm/fields-preview/GeoPointPreview/ef3d6b85-cc8b-46fb-9447-a329d2ab270c.json deleted file mode 100644 index 17aab1c6495..00000000000 --- a/packages/catalog-realm/fields-preview/GeoPointPreview/ef3d6b85-cc8b-46fb-9447-a329d2ab270c.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "data": { - "meta": { - "adoptsFrom": { - "name": "GeoPointPreview", - "module": "../geo-point" - } - }, - "type": "card", - "attributes": { - "cardInfo": { - "notes": null, - "name": null, - "summary": null, - "cardThumbnailURL": null - }, - "geoPoint": { - "lat": 3.4223567966488933, - "lon": 101.78656211516571 - } - }, - "relationships": { - "cardInfo.theme": { - "links": { - "self": null - } - } - } - } -} diff --git a/packages/catalog-realm/fields-preview/GeoSearchPointPreview/2b042a26-1932-47bd-b7c8-92d37e9a7e59.json b/packages/catalog-realm/fields-preview/GeoSearchPointPreview/2b042a26-1932-47bd-b7c8-92d37e9a7e59.json deleted file mode 100644 index 558898f767d..00000000000 --- a/packages/catalog-realm/fields-preview/GeoSearchPointPreview/2b042a26-1932-47bd-b7c8-92d37e9a7e59.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "data": { - "meta": { - "adoptsFrom": { - "name": "GeoSearchPointPreview", - "module": "../geo-search-point" - } - }, - "type": "card", - "attributes": { - "cardInfo": { - "notes": null, - "name": null, - "summary": null, - "cardThumbnailURL": null - }, - "geoSearchPoint": { - "lat": 4.5986817, - "lon": 101.0900236, - "searchKey": "ipoh" - } - }, - "relationships": { - "cardInfo.theme": { - "links": { - "self": null - } - } - } - } -} diff --git a/packages/catalog-realm/fields-preview/geo-point.gts b/packages/catalog-realm/fields-preview/geo-point.gts deleted file mode 100644 index 89b75f5eb2a..00000000000 --- a/packages/catalog-realm/fields-preview/geo-point.gts +++ /dev/null @@ -1,34 +0,0 @@ -import GeoPointField from '../fields/geo-point'; - -import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; -import { Component } from 'https://cardstack.com/base/card-api'; -import { FieldContainer } from '@cardstack/boxel-ui/components'; - -export class GeoPointPreview extends CardDef { - @field geoPoint = contains(GeoPointField); - - static displayName = 'Geo Point Preview'; - static isolated = class Isolated extends Component { - - }; -} diff --git a/packages/catalog-realm/fields-preview/geo-search-point.gts b/packages/catalog-realm/fields-preview/geo-search-point.gts deleted file mode 100644 index 8aa24a43998..00000000000 --- a/packages/catalog-realm/fields-preview/geo-search-point.gts +++ /dev/null @@ -1,34 +0,0 @@ -import GeoSearchPointField from '../fields/geo-search-point'; - -import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; -import { Component } from 'https://cardstack.com/base/card-api'; -import { FieldContainer } from '@cardstack/boxel-ui/components'; - -export class GeoSearchPointPreview extends CardDef { - @field geoSearchPoint = contains(GeoSearchPointField); - - static displayName = 'Geo Search Point Preview'; - static isolated = class Isolated extends Component { - - }; -} diff --git a/packages/catalog-realm/fields/geo-point.gts b/packages/catalog-realm/fields/geo-point.gts index 16e6b720e40..f966dcde440 100644 --- a/packages/catalog-realm/fields/geo-point.gts +++ b/packages/catalog-realm/fields/geo-point.gts @@ -5,230 +5,107 @@ import { field, } from 'https://cardstack.com/base/card-api'; import NumberField from 'https://cardstack.com/base/number'; -import { action } from '@ember/object'; -import MapIcon from '@cardstack/boxel-icons/map'; import MapPinIcon from '@cardstack/boxel-icons/map-pin'; -import { FieldContainer } from '@cardstack/boxel-ui/components'; -import { MapRender } from '../components/map-render'; -class AtomTemplate extends Component { - get displayValue() { - const lat = this.args.model?.lat; - const lon = this.args.model?.lon; +import GeoPointEditField from './geo-point/components/geo-point-edit-field'; +import GeoPointMapPicker from './geo-point/components/geo-point-map-picker'; - if (lat != null && lon != null) { - return `${lat}, ${lon}`; - } - return 'No coordinates'; - } +// --- Configuration Types --- - +export interface GeoPointMapOptions { + tileserverUrl?: string; + mapHeight?: string; } -class EmbeddedTemplate extends Component { - get displayValue() { - const lat = this.args.model?.lat; - const lon = this.args.model?.lon; +export type GeoPointVariant = 'standard' | 'map-picker'; - if (lat != null && lon != null) { - return `${lat}, ${lon}`; +export type GeoPointConfiguration = + | { + variant?: 'standard'; + options?: GeoPointBaseOptions; } - return 'No coordinates'; - } + | { + variant?: 'map-picker'; + options?: GeoPointBaseOptions & GeoPointMapOptions; + }; - get coordinates() { - const lat = this.args.model?.lat; - const lon = this.args.model?.lon; +// --- Dispatcher Templates --- - if (lat != null && lon != null) { - return [{ lat, lng: lon }]; - } - return []; - } +export class GeoPointEdit extends Component { + +} - get hasValidCoordinates() { - return this.args.model?.lat != null && this.args.model?.lon != null; +export class GeoPointEmbedded extends Component { + get config(): GeoPointConfiguration { + return (this.args.configuration as GeoPointConfiguration) ?? {}; } - @action - updateCoordinate(coordinate: { lat: number; lng: number }) { - if (this.args.model) { - this.args.model.lat = coordinate.lat; - this.args.model.lon = coordinate.lng; + get mapOptions() { + const { options, variant } = this.config; + if (variant !== 'map-picker' || !options) { + return undefined; } + const mapOpts = options as GeoPointBaseOptions & GeoPointMapOptions; + return { + mapHeight: mapOpts.mapHeight, + tileserverUrl: mapOpts.tileserverUrl, + }; } } -class EditTemplate extends Component { +export class GeoPointAtom extends Component { + get displayValue(): string { + const { lat, lon } = this.args.model; + if (lat != null && lon != null) { + return `${lat}, ${lon}`; + } + return 'No location'; + } + } +// --- Field Definition --- + export default class GeoPointField extends FieldDef { static displayName = 'Geo Point'; static icon = MapPinIcon; @@ -236,7 +113,7 @@ export default class GeoPointField extends FieldDef { @field lat = contains(NumberField); @field lon = contains(NumberField); - static atom = AtomTemplate; - static embedded = EmbeddedTemplate; - static edit = EditTemplate; + static atom = GeoPointAtom; + static embedded = GeoPointEmbedded; + static edit = GeoPointEdit; } diff --git a/packages/catalog-realm/fields/geo-point/components/current-location-addon.gts b/packages/catalog-realm/fields/geo-point/components/current-location-addon.gts new file mode 100644 index 00000000000..fd4be96ad0f --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/current-location-addon.gts @@ -0,0 +1,94 @@ +import GlimmerComponent from '@glimmer/component'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import { BoxelButton } from '@cardstack/boxel-ui/components'; +import { not } from '@cardstack/boxel-ui/helpers'; +import { task } from 'ember-concurrency'; +import MapPinIcon from '@cardstack/boxel-icons/map-pin'; +import type { GeoModel } from '../util/index'; + +interface CurrentLocationAddonSignature { + Args: { + model: GeoModel; + canEdit?: boolean; + }; +} + +function getPosition(): Promise { + return new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + timeout: 10000, + maximumAge: 0, + }); + }); +} + +export default class CurrentLocationAddon extends GlimmerComponent { + private locateTask = task(async () => { + if (!navigator.geolocation) { + throw new Error('Geolocation is not supported by this browser.'); + } + const position = await getPosition(); + if (this.args.model) { + this.args.model.lat = position.coords.latitude; + this.args.model.lon = position.coords.longitude; + } + }); + + get errorMessage(): string | null { + const error = this.locateTask.last?.error; + if (!error) return null; + return error instanceof GeolocationPositionError + ? `Location error: ${error.message}` + : (error as Error).message; + } + + @action + getCurrentLocation() { + this.locateTask.perform(); + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/components/geo-point-coordinate-input.gts b/packages/catalog-realm/fields/geo-point/components/geo-point-coordinate-input.gts new file mode 100644 index 00000000000..7b96da9ac56 --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/geo-point-coordinate-input.gts @@ -0,0 +1,126 @@ +import GlimmerComponent from '@glimmer/component'; +import { action } from '@ember/object'; +import { not } from '@cardstack/boxel-ui/helpers'; +import { FieldContainer, BoxelInput } from '@cardstack/boxel-ui/components'; +import type { GeoModel } from '../util/index'; + +interface CoordinateInputSignature { + Args: { + model: GeoModel; + canEdit?: boolean; + }; +} + +export default class GeoPointCoordinateInput extends GlimmerComponent { + get latState(): 'valid' | 'invalid' | 'none' { + const lat = this.args.model.lat; + if (lat == null) return 'none'; + if (Number.isNaN(lat)) return 'invalid'; + return lat >= -90 && lat <= 90 ? 'valid' : 'invalid'; + } + + get latError(): string | undefined { + return this.latState === 'invalid' + ? 'Latitude must be between -90 and 90' + : undefined; + } + + get lonState(): 'valid' | 'invalid' | 'none' { + const lon = this.args.model.lon; + if (lon == null) return 'none'; + if (Number.isNaN(lon)) return 'invalid'; + return lon >= -180 && lon <= 180 ? 'valid' : 'invalid'; + } + + get lonError(): string | undefined { + return this.lonState === 'invalid' + ? 'Longitude must be between -180 and 180' + : undefined; + } + + @action + setLat(val: string) { + if (this.args.model) { + if (val === '') { + this.args.model.lat = null; + } else { + const num = Number(val); + this.args.model.lat = Number.isNaN(num) ? null : num; + } + } + } + + @action + setLon(val: string) { + if (this.args.model) { + if (val === '') { + this.args.model.lon = null; + } else { + const num = Number(val); + this.args.model.lon = Number.isNaN(num) ? null : num; + } + } + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/components/geo-point-edit-field.gts b/packages/catalog-realm/fields/geo-point/components/geo-point-edit-field.gts new file mode 100644 index 00000000000..1ca23485f98 --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/geo-point-edit-field.gts @@ -0,0 +1,101 @@ +import GlimmerComponent from '@glimmer/component'; +import { eq, or } from '@cardstack/boxel-ui/helpers'; +import type { GeoModel } from '../util/index'; +import type { + GeoPointConfiguration, + GeoPointMapOptions, + GeoPointVariant, +} from '../../geo-point'; +import GeoPointCoordinateInput from './geo-point-coordinate-input'; +import GeoPointMapPicker from './geo-point-map-picker'; +import CurrentLocationAddon from './current-location-addon'; +import QuickLocationsAddon from './quick-locations-addon'; + +interface GeoPointEditFieldSignature { + Args: { + model: GeoModel; + canEdit?: boolean; + configuration?: GeoPointConfiguration; + }; +} + +export default class GeoPointEditField extends GlimmerComponent { + get variant(): GeoPointVariant { + return this.args.configuration?.variant ?? 'standard'; + } + + private get options() { + return this.args.configuration?.options ?? {}; + } + + get showCurrentLocation(): boolean { + return this.options.showCurrentLocation ?? false; + } + + get quickLocations(): string[] { + return this.options.quickLocations ?? []; + } + + get hasQuickLocations(): boolean { + return this.quickLocations.length > 0; + } + + get mapOptions(): GeoPointMapOptions | undefined { + const config = this.args.configuration; + if (config?.variant !== 'map-picker') return undefined; + const opts = config.options; + return opts + ? { tileserverUrl: opts.tileserverUrl, mapHeight: opts.mapHeight } + : undefined; + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/components/geo-point-map-picker.gts b/packages/catalog-realm/fields/geo-point/components/geo-point-map-picker.gts new file mode 100644 index 00000000000..12bda27a0ef --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/geo-point-map-picker.gts @@ -0,0 +1,162 @@ +import GlimmerComponent from '@glimmer/component'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; +import MapPinIcon from '@cardstack/boxel-icons/map-pin'; +import { or } from '@cardstack/boxel-ui/helpers'; +import { MapRender, type Coordinate } from '../../../components/map-render'; +import { hasValidCoordinates, formatCoordinates } from '../util/index'; +import type { GeoModel } from '../util/index'; +import type { GeoPointMapOptions } from '../../geo-point'; + +interface MapPickerSignature { + Args: { + model: GeoModel; + options?: GeoPointMapOptions; + canEdit?: boolean; + }; +} + +export default class GeoPointMapPicker extends GlimmerComponent { + get hasCoordinates() { + return hasValidCoordinates(this.args.model); + } + + get coordinates(): Coordinate[] { + if (!this.hasCoordinates) return []; + return [{ lat: this.args.model.lat!, lng: this.args.model.lon! }]; + } + + get coordinateDisplay() { + if (this.hasCoordinates) { + return formatCoordinates(this.args.model.lat, this.args.model.lon); + } + return this.args.canEdit + ? 'Click the map to place a pin' + : 'No location set'; + } + + get mapConfig(): { + tileserverUrl?: string; + disableMapClick?: boolean; + } { + const config: { + tileserverUrl?: string; + disableMapClick?: boolean; + } = {}; + if (this.args.options?.tileserverUrl) { + config.tileserverUrl = this.args.options.tileserverUrl; + } + if (!this.args.canEdit) { + config.disableMapClick = true; + } + return config; + } + + get mapContainerStyle() { + const height = this.args.options?.mapHeight; + return height ? htmlSafe(`height: ${height}`) : undefined; + } + + @action + handleMapClick(coordinate: Coordinate) { + if (this.args.canEdit && this.args.model) { + this.args.model.lat = coordinate.lat; + this.args.model.lon = coordinate.lng; + } + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/components/quick-locations-addon.gts b/packages/catalog-realm/fields/geo-point/components/quick-locations-addon.gts new file mode 100644 index 00000000000..9c0061c986c --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/components/quick-locations-addon.gts @@ -0,0 +1,74 @@ +import GlimmerComponent from '@glimmer/component'; +import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { tracked } from '@glimmer/tracking'; +import { eq, not } from '@cardstack/boxel-ui/helpers'; +import { BoxelButton, FieldContainer } from '@cardstack/boxel-ui/components'; +import { task } from 'ember-concurrency'; +import type { GeoModel } from '../util/index'; +import { geocodeLocation } from '../util/index'; + +interface QuickLocationsAddonSignature { + Args: { + model: GeoModel; + locations: string[]; + canEdit?: boolean; + }; +} + +export default class QuickLocationsAddon extends GlimmerComponent { + @tracked loadingLocation: string | null = null; + + private geocodeAndSet = task(async (locationName: string) => { + this.loadingLocation = locationName; + try { + const result = await geocodeLocation(locationName); + if (result && this.args.model) { + this.args.model.lat = result.lat; + this.args.model.lon = result.lon; + } + } catch (error) { + console.error('Failed to geocode location:', error); + } finally { + this.loadingLocation = null; + } + }); + + @action + selectQuickLocation(locationName: string) { + this.geocodeAndSet.perform(locationName); + } + + +} diff --git a/packages/catalog-realm/fields/geo-point/util/index.gts b/packages/catalog-realm/fields/geo-point/util/index.gts new file mode 100644 index 00000000000..742be73d21a --- /dev/null +++ b/packages/catalog-realm/fields/geo-point/util/index.gts @@ -0,0 +1,61 @@ +export interface GeoModel { + lat?: number | null; + lon?: number | null; + searchKey?: string | null; +} + +export function hasValidCoordinates(model: GeoModel | null | undefined): boolean { + if (!model) return false; + const { lat, lon } = model; + return ( + lat != null && + lon != null && + typeof lat === 'number' && + typeof lon === 'number' && + !Number.isNaN(lat) && + !Number.isNaN(lon) + ); +} + +export function formatCoordinates( + lat: number | null | undefined, + lon: number | null | undefined, + precision?: number, +): string { + if (lat == null || lon == null) return 'No coordinates'; + const p = precision ?? 6; + return `${lat.toFixed(p)}, ${lon.toFixed(p)}`; +} + +export async function geocodeLocation( + query: string, +): Promise<{ lat: number; lon: number } | null> { + if (!query || query.trim() === '') return null; + + const response = await fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1`, + ); + + if (!response.ok) { + throw new Error(`Geocoding failed: ${response.status}`); + } + + const data = await response.json(); + if (!data || data.length === 0) return null; + + const lat = parseFloat(data[0].lat); + const lon = parseFloat(data[0].lon); + + if ( + Number.isNaN(lat) || + Number.isNaN(lon) || + lat < -90 || + lat > 90 || + lon < -180 || + lon > 180 + ) { + return null; + } + + return { lat, lon }; +} diff --git a/packages/catalog-realm/fields/geo-search-point.gts b/packages/catalog-realm/fields/geo-search-point.gts index e8113608564..25991a08ca6 100644 --- a/packages/catalog-realm/fields/geo-search-point.gts +++ b/packages/catalog-realm/fields/geo-search-point.gts @@ -1,427 +1,167 @@ -import { action } from '@ember/object'; -import { task } from 'ember-concurrency'; -import { debounce } from 'lodash'; -import { BoxelInput } from '@cardstack/boxel-ui/components'; -import MapIcon from '@cardstack/boxel-icons/map'; -import StringField from 'https://cardstack.com/base/string'; import { Component, contains, field, } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import MapPinIcon from '@cardstack/boxel-icons/map-pin'; import GeoPointField from './geo-point'; -import { MapRender, type Coordinate } from '../components/map-render'; +import GeoSearchPointEditField from './geo-search-point/components/geo-search-point-edit-field'; +import GeoPointMapPicker from './geo-point/components/geo-point-map-picker'; -class AtomTemplate extends Component { - get displayValue() { - const address = this.args.model?.searchKey; - const lat = this.args.model?.lat; - const lon = this.args.model?.lon; +// --- Configuration Types --- - if (address && address.trim() !== '') { - return address; - } +interface GeoSearchPointCommonOptions { + placeholder?: string; + tileserverUrl?: string; + mapHeight?: string; +} - if (lat != null && lon != null) { - return `${lat}, ${lon}`; - } +interface GeoSearchPointWithSearchResults extends GeoSearchPointCommonOptions { + showTopSearchResults: true; + topSearchResultsLimit?: number; + showRecentSearches?: boolean; + recentSearchesLimit?: number; +} - return 'No location'; - } +interface GeoSearchPointWithoutSearchResults extends GeoSearchPointCommonOptions { + showTopSearchResults?: false; +} -