This commit is contained in:
parent
3abf69edbd
commit
1c2d245f7c
@ -6,22 +6,12 @@ exports.up = function(knex) {
|
||||
|
||||
return knex.schema
|
||||
.createTable('users', function (table) {
|
||||
table.uuid('id')
|
||||
.primary()
|
||||
.defaultTo(knex.raw(`gen_random_uuid()`));
|
||||
table.string('first_name', 255)
|
||||
.notNullable();
|
||||
table.string('last_name', 255)
|
||||
.notNullable();
|
||||
table.string('email', 255)
|
||||
.unique()
|
||||
.notNullable();
|
||||
table.uuid('id').primary().defaultTo(knex.raw(`gen_random_uuid()`));
|
||||
table.string('first_name', 255).notNullable();
|
||||
table.string('last_name', 255).notNullable();
|
||||
table.string('email', 255).unique().notNullable();
|
||||
table.timestamps(true, true);
|
||||
})
|
||||
// .createTable('products', function (table) {
|
||||
// table.increments('id');
|
||||
// table.decimal('price').notNullable();
|
||||
// table.string('name', 1000).notNullable();
|
||||
// });
|
||||
};
|
||||
|
||||
/**
|
||||
@ -29,7 +19,5 @@ exports.up = function(knex) {
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function(knex) {
|
||||
return knex.schema
|
||||
// .dropTable('products')
|
||||
.dropTable('users');
|
||||
return knex.schema.dropTable('users');
|
||||
};
|
||||
|
||||
23
apps/blakus-api/migrations/20250310143023_places.js
Normal file
23
apps/blakus-api/migrations/20250310143023_places.js
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function(knex) {
|
||||
|
||||
return knex.schema
|
||||
.createTable('places', function (table) {
|
||||
table.uuid('id').primary().defaultTo(knex.raw(`gen_random_uuid()`));
|
||||
table.string('name', 255).notNullable();
|
||||
table.specificType('coordinates', 'GEOGRAPHY(Point, 4326)').notNullable();
|
||||
table.string('externalId', 255);
|
||||
table.timestamps(true, true);
|
||||
})
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.dropTable('places');
|
||||
};
|
||||
30
apps/blakus-api/seeds/places.js
Normal file
30
apps/blakus-api/seeds/places.js
Normal file
@ -0,0 +1,30 @@
|
||||
const { fakerLV : faker } = require('@faker-js/faker');
|
||||
|
||||
const PLACE_COUNT = 1000;
|
||||
const BBOX_RIGA = [23.755875,56.815914,24.466553,57.067617];
|
||||
const [ BBOX_MIN_LAT, BBOX_MIN_LON, BBOX_MAX_LAT, BBOX_MAX_LON ] = BBOX_RIGA;
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.seed = async function(knex) {
|
||||
|
||||
const createPlace = () => {
|
||||
const coords = [
|
||||
faker.location.latitude({ min: BBOX_MIN_LAT, max: BBOX_MAX_LAT }),
|
||||
faker.location.longitude({ min: BBOX_MIN_LON, max: BBOX_MAX_LON })
|
||||
];
|
||||
|
||||
return {
|
||||
name: faker.company.name(),
|
||||
coordinates: knex.raw('ST_SetSRID(ST_MakePoint(?, ?), 4326)', coords),
|
||||
}
|
||||
};
|
||||
|
||||
// Deletes ALL existing entries
|
||||
await knex('places').del()
|
||||
await knex('places').insert([
|
||||
...new Array(PLACE_COUNT).fill(() => createPlace()).map((fn, i) => fn(i)),
|
||||
]);
|
||||
};
|
||||
@ -2,25 +2,25 @@ const { fakerLV : faker } = require('@faker-js/faker');
|
||||
|
||||
const USER_COUNT = 500;
|
||||
|
||||
const createUser = ({
|
||||
sex = faker.person.sexType(),
|
||||
first_name = `${faker.person.firstName(sex)}`,
|
||||
last_name = faker.person.lastName(sex),
|
||||
email = faker.internet.email({ lastName: last_name, firstName: first_name }).toLocaleLowerCase(),
|
||||
} = {}) => {
|
||||
return {
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.seed = async function(knex) {
|
||||
// Deletes ALL existing entries
|
||||
|
||||
const createUser = ({
|
||||
sex = faker.person.sexType(),
|
||||
first_name = `${faker.person.firstName(sex)}`,
|
||||
last_name = faker.person.lastName(sex),
|
||||
email = faker.internet.email({ lastName: last_name, firstName: first_name }).toLocaleLowerCase(),
|
||||
} = {}) => {
|
||||
return {
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
}
|
||||
};
|
||||
|
||||
await knex('users').del()
|
||||
await knex('users').insert([
|
||||
createUser({
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { OverpassService } from '../services/overpass';
|
||||
// import { AMENITIES, OverpassService } from '../services/overpass';
|
||||
import { OverpassQuerySchema, OverpassQueryType } from '../schemas/overpass';
|
||||
import db from '../../db';
|
||||
|
||||
const overpassService = new OverpassService();
|
||||
// const overpassService = new OverpassService();
|
||||
|
||||
export default async function (fastify: FastifyInstance) {
|
||||
|
||||
@ -15,6 +16,7 @@ export default async function (fastify: FastifyInstance) {
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
amenity: { type: 'string' },
|
||||
coords: {
|
||||
@ -24,21 +26,66 @@ export default async function (fastify: FastifyInstance) {
|
||||
lon: { type: 'number' }
|
||||
}
|
||||
},
|
||||
tags: { type: 'object', additionalProperties: true }
|
||||
affinity: { type: 'number' },
|
||||
// tags: { type: 'object', additionalProperties: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
config: {
|
||||
serverCache: { ttl: 10000 }
|
||||
// serverCache: { ttl: 1 * 60 * 1000 }
|
||||
}
|
||||
}, async function (request, reply) {
|
||||
const {lat, lon} = request.query;
|
||||
const elements = await overpassService.getAmenitiesAroundLocation({
|
||||
coords: { lat, lon },
|
||||
amenities: ['restaurant', 'cafe', 'bar', 'pub', 'biergarten', 'fast_food', 'food_court', 'ice_cream']
|
||||
});
|
||||
reply.send(elements);
|
||||
|
||||
const knownPlaces = await db.select(
|
||||
'id',
|
||||
'name',
|
||||
'externalId',
|
||||
db.raw('ST_Y(coordinates::geometry) AS lat'),
|
||||
db.raw('ST_X(coordinates::geometry) AS lon')
|
||||
).whereRaw(
|
||||
'ST_DWithin(coordinates, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)',
|
||||
[lon, lat, 2000]
|
||||
).from('places');
|
||||
|
||||
// const externalIds = await db.select('externalId').from('places').where('externalId', 'like', 'osm:%');
|
||||
|
||||
// console.log(externalIds);
|
||||
|
||||
// overpassService.getAmenitiesAroundLocation({
|
||||
// radius: 1000,
|
||||
// coords: { lat, lon },
|
||||
// amenities: AMENITIES.Sustanance
|
||||
// }).then(result => {
|
||||
|
||||
// const insertItems = result
|
||||
// .filter(node => {
|
||||
// if (!node.title || !node.coords) return false;
|
||||
// return !externalIds.some(({ externalId }) => externalId === `osm:${node.id}`);
|
||||
// })
|
||||
// .map(node => ({
|
||||
// name: node.title,
|
||||
// externalId: `osm:${node.id}`,
|
||||
// coordinates: db.raw('ST_SetSRID(ST_MakePoint(?, ?), 4326)', [node.coords.lon, node.coords.lat])
|
||||
// }));
|
||||
|
||||
// if (insertItems.length) db('places').insert(insertItems).then((success) => console.log({success}), (error) => console.log({error}));
|
||||
|
||||
// });
|
||||
|
||||
const blakus = knownPlaces.map(place => ({
|
||||
id: place.id,
|
||||
title: place.name,
|
||||
amenity: 'pub',
|
||||
coords: {
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
},
|
||||
affinity: Math.floor(Math.random() * 101) // TODO: this is just a placeholder for "affinity" calculation
|
||||
}));
|
||||
|
||||
reply.send(blakus);
|
||||
});
|
||||
}
|
||||
|
||||
50
apps/blakus-api/src/app/routes/places.ts
Normal file
50
apps/blakus-api/src/app/routes/places.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import db from '../../db';
|
||||
|
||||
export default async function (fastify: FastifyInstance) {
|
||||
|
||||
fastify.get('/places', {
|
||||
schema: {
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
places: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
coords: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lat: { type: 'number' },
|
||||
lon: { type: 'number' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
config: {
|
||||
// serverCache: { ttl: 10000 }
|
||||
}
|
||||
}, async function (request, reply) {
|
||||
|
||||
const placesResults = await db.select(
|
||||
'name',
|
||||
db.raw('ST_Y(coordinates::geometry) AS lat'),
|
||||
db.raw('ST_X(coordinates::geometry) AS lon')
|
||||
).from('places').limit(500);
|
||||
|
||||
const places = placesResults.map(({ name, lat, lon}) => ({
|
||||
name,
|
||||
coords: { lat, lon }
|
||||
}));
|
||||
|
||||
reply.send({ places });
|
||||
});
|
||||
}
|
||||
@ -48,6 +48,12 @@ type OverpassQuerySettings = {
|
||||
date?: Date;
|
||||
}
|
||||
|
||||
export const AMENITIES = {
|
||||
get Sustanance() {
|
||||
return ['restaurant', 'cafe', 'bar', 'pub', 'biergarten', 'fast_food', 'food_court', 'ice_cream']
|
||||
}
|
||||
};
|
||||
|
||||
function overpassQueryTemplate ({ out = 'json', timeout, maxsize, bbox = [34.016242,-24.433594,71.663663,48.164063], date}: OverpassQuerySettings, statements: string[] = ['out geom']) {
|
||||
return function (strings, ...expressions) {
|
||||
let query = [
|
||||
@ -82,17 +88,18 @@ export class OverpassService {
|
||||
|
||||
const { lat, lon } = coords;
|
||||
|
||||
const opq = overpassQueryTemplate({ out: 'json', timeout: 25 });
|
||||
const opq = overpassQueryTemplate({ out: 'json', timeout: 60 });
|
||||
|
||||
const query = opq`node(around:${radius}, ${lat}, ${lon})["amenity"~"${amenities.join('|')}"];`;
|
||||
|
||||
const result = await this.overpassQuery<NodeElement>(query);
|
||||
|
||||
return result.elements
|
||||
const output = result.elements
|
||||
.map(item => {
|
||||
const { tags } = item;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title: tags.name,
|
||||
amenity: tags.amenity,
|
||||
tags,
|
||||
@ -100,6 +107,8 @@ export class OverpassService {
|
||||
}
|
||||
})
|
||||
.filter(i => amenities.includes(i.amenity)); // query returns some random results. need to look into this
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
async overpassQuery<T = OverpassResultElement>(query: string): Promise<OverpassResult<T>> {
|
||||
|
||||
@ -12,4 +12,5 @@ project.ext {
|
||||
|
||||
// useKotlin = true
|
||||
// kotlinVersion = "1.6.0"
|
||||
cartoMobileVersion = '4.4.7'
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
"license": "SEE LICENSE IN <your-license-filename>",
|
||||
"repository": "<fill-your-repository-here>",
|
||||
"dependencies": {
|
||||
"@nativescript-community/ui-carto": "^1.8.18",
|
||||
"@nativescript/core": "*",
|
||||
"@nativescript/geolocation": "^9.0.0",
|
||||
"@nativescript/localize": "^5.2.0"
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import { EventData, Page } from '@nativescript/core';
|
||||
import { HelloWorldModel } from './main-view-model';
|
||||
import { CartoMap } from '@nativescript-community/ui-carto/ui';
|
||||
import { MapModel } from './map-view-model';
|
||||
|
||||
export function navigatingTo(args: EventData) {
|
||||
const page = <Page>args.object;
|
||||
page.bindingContext = new HelloWorldModel();
|
||||
|
||||
// There are issues with binding the events in the xml file due to naming
|
||||
// conflicts with the map events (e.g. mapReady) so we use a reference to the map instead
|
||||
const map = page.getViewById('cartoMap');
|
||||
map.bindingContext = new MapModel(map as CartoMap);
|
||||
page.bindingContext = new HelloWorldModel(map.bindingContext);
|
||||
}
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
|
||||
<Page xmlns="http://schemas.nativescript.org/tns.xsd" xmlns:map="@nativescript-community/ui-carto/ui" navigatingTo="navigatingTo" class="page">
|
||||
<Page.actionBar>
|
||||
<ActionBar title="Blakus" icon="" class="action-bar">
|
||||
</ActionBar>
|
||||
</Page.actionBar>
|
||||
<StackLayout class="p-20">
|
||||
<Label text="Tap the button" class="h1 text-center"/>
|
||||
<StackLayout>
|
||||
<Label text="Tap the button"/>
|
||||
<Button text="TAP" tap="{{ onTap }}" />
|
||||
<Button text="Location" tap="{{ onLocationTap }}" />
|
||||
<Label text="{{ message }}" class="h2 text-center" textWrap="true"/>
|
||||
<ListView items="{{ items }}" itemTap="{{ onItemTap }}">
|
||||
<Label text="{{ message }}" textWrap="true"/>
|
||||
<map:CartoMap id="cartoMap" row="0" zoom="15" width="100%" height="100%"></map:CartoMap>
|
||||
<!-- <ListView items="{{ items }}" itemTap="{{ onItemTap }}">
|
||||
<ListView.itemTemplate>
|
||||
<!-- The item template can only have a single root element -->
|
||||
<GridLayout padding="16" columns="20, *, *">
|
||||
<ContentView width="20" height="20" borderRadius="20" backgroundColor="#65adf1" />
|
||||
<Label text="{{ title }}" col="1" textWrap="true" marginLeft="8" />
|
||||
<Label text="{{ L(`amenity.${amenity}`) }}" col="2" />
|
||||
</GridLayout>
|
||||
</ListView.itemTemplate>
|
||||
</ListView>
|
||||
</ListView> -->
|
||||
</StackLayout>
|
||||
</Page>
|
||||
|
||||
@ -2,6 +2,7 @@ import { Observable, Http, type ItemEventData, ListView } from '@nativescript/co
|
||||
import { DeviceLocation } from './location';
|
||||
import type { Location } from '@nativescript/geolocation';
|
||||
import { localize } from '@nativescript/localize';
|
||||
import { MapModel } from './map-view-model';
|
||||
|
||||
type ReverseGeocodeResult = {
|
||||
valsts: string;
|
||||
@ -22,20 +23,15 @@ type ReverseGeocodeResult = {
|
||||
}
|
||||
|
||||
type BlakusItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
amenity: string;
|
||||
tags: Record<string, string>;
|
||||
}
|
||||
|
||||
type OverpassResult = {
|
||||
version: number;
|
||||
generator: string;
|
||||
osm3s: Record<string, string>;
|
||||
elements: {
|
||||
type: string,
|
||||
id: number,
|
||||
tags: Record<string, string>;
|
||||
}[]
|
||||
affinity: number;
|
||||
coords: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
}
|
||||
|
||||
async function aWait(timeout: number) {
|
||||
@ -43,38 +39,26 @@ async function aWait(timeout: number) {
|
||||
}
|
||||
|
||||
export class HelloWorldModel extends Observable {
|
||||
private _counter: number;
|
||||
private _message: string;
|
||||
private _items: BlakusItem[];
|
||||
private _locationServiceBaseURL = 'https://api.kartes.lv/v3/KVDM_mwwKi/'
|
||||
private _blakusBaseURL = 'https://192.168.0.155:3000';
|
||||
#items: BlakusItem[];
|
||||
#locationServiceBaseURL = 'https://api.kartes.lv/v3/KVDM_mwwKi/'
|
||||
#blakusBaseURL = 'https://192.168.0.155:3000';
|
||||
#map: MapModel;
|
||||
|
||||
constructor() {
|
||||
constructor(map: MapModel) {
|
||||
super();
|
||||
|
||||
// Initialize default values.
|
||||
this._counter = 42;
|
||||
this.updateMessage();
|
||||
}
|
||||
|
||||
get message(): string {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
set message(value: string) {
|
||||
if (this._message !== value) {
|
||||
this._message = value;
|
||||
this.notifyPropertyChange('message', value);
|
||||
}
|
||||
this.#map = map;
|
||||
this.#map.on('mapMoveEnded', () => {
|
||||
this.getNearbyAmenities(this.#map.focusPosition);
|
||||
});
|
||||
}
|
||||
|
||||
get items(): BlakusItem[] {
|
||||
return this._items || [];
|
||||
return this.#items || [];
|
||||
}
|
||||
|
||||
set items(value: BlakusItem[]) {
|
||||
if (this._items !== value) {
|
||||
this._items = value;
|
||||
if (this.#items !== value) {
|
||||
this.#items = value;
|
||||
this.notifyPropertyChange('items', value);
|
||||
}
|
||||
}
|
||||
@ -84,9 +68,7 @@ export class HelloWorldModel extends Observable {
|
||||
params.set('lat', latitude.toString());
|
||||
params.set('lon', longitude.toString());
|
||||
|
||||
this.message = [latitude, longitude].join(', ');
|
||||
|
||||
const url = new URL(`reverse_geocoding?${params}`, this._locationServiceBaseURL).toString();
|
||||
const url = new URL(`reverse_geocoding?${params}`, this.#locationServiceBaseURL).toString();
|
||||
const result = await Http.getJSON<ReverseGeocodeResult>(url);
|
||||
console.log(result);
|
||||
|
||||
@ -94,20 +76,33 @@ export class HelloWorldModel extends Observable {
|
||||
}
|
||||
|
||||
getBlakus(params = new URLSearchParams()) {
|
||||
const url = new URL(`blakus?${params.toString()}`, this._blakusBaseURL).toString();
|
||||
console.log('blakus query', url)
|
||||
return Http.getJSON<OverpassResult["elements"]>(url)
|
||||
const url = new URL(`blakus?${params.toString()}`, this.#blakusBaseURL).toString();
|
||||
return Http.getJSON<BlakusItem[]>(url)
|
||||
}
|
||||
|
||||
async getOverpassAmenities({latitude, longitude}: Location) {
|
||||
async getNearbyAmenities({latitude, longitude}: Pick<Location, 'latitude' | 'longitude'>) {
|
||||
const precision = 4; // limit uncached hits to API
|
||||
const params = new URLSearchParams();
|
||||
params.set('lat', latitude.toString());
|
||||
params.set('lon', longitude.toString());
|
||||
params.set('lat', latitude.toFixed(precision).toString());
|
||||
params.set('lon', longitude.toFixed(precision).toString());
|
||||
|
||||
const result = await this.getBlakus(params);
|
||||
console.log(result);
|
||||
this.items = result.map(({ tags }) => ({ title: tags.name, amenity: tags.amenity, tags}))
|
||||
// .filter(i => amenities.includes(i.amenity));
|
||||
this.items = await this.getBlakus(params);
|
||||
|
||||
this.items.forEach(({ coords, title, amenity, affinity, id }) => {
|
||||
this.#map.addMarker({
|
||||
id,
|
||||
position: {
|
||||
latitude: coords.lat,
|
||||
longitude: coords.lon
|
||||
},
|
||||
detail: {
|
||||
title,
|
||||
description: localize(`amenity.${amenity}`),
|
||||
affinity
|
||||
}
|
||||
})
|
||||
});
|
||||
this.#map.redrawMarkers();
|
||||
}
|
||||
|
||||
async postalPolygons(country = 'LV', code: string, mode: 'isolate' | 'collect' | 'union' = 'union') {
|
||||
@ -118,7 +113,7 @@ export class HelloWorldModel extends Observable {
|
||||
params.set('union_mode', mode);
|
||||
params.set('wgs84', '');
|
||||
// params.set('wkt', '');
|
||||
const url = new URL(`postal_codes?${params}`, this._locationServiceBaseURL).toString();
|
||||
const url = new URL(`postal_codes?${params}`, this.#locationServiceBaseURL).toString();
|
||||
|
||||
const result = await Http.getJSON(url);
|
||||
console.log(url);
|
||||
@ -128,25 +123,19 @@ export class HelloWorldModel extends Observable {
|
||||
|
||||
async onLocationTap() {
|
||||
const location = await DeviceLocation.getDeviceLocation();
|
||||
await this.getOverpassAmenities(location);
|
||||
const { latitude, longitude } = location;
|
||||
if (this.#map) {
|
||||
this.#map.setPosition({ longitude, latitude }, 500);
|
||||
}
|
||||
await this.getNearbyAmenities(location);
|
||||
}
|
||||
|
||||
onTap() {
|
||||
this._counter--;
|
||||
|
||||
if (this._counter <= 0) {
|
||||
this.updateMessage('Hoorraaay! You unlocked the NativeScript clicker achievement!');
|
||||
} else {
|
||||
this.updateMessage(`${this._counter} taps left`);
|
||||
}
|
||||
async onTap() {
|
||||
await this.getNearbyAmenities(this.#map.focusPosition);
|
||||
}
|
||||
|
||||
onItemTap(args: ItemEventData) {
|
||||
const listView = args.object as ListView
|
||||
console.log('Tapped item', listView.items[args.index])
|
||||
}
|
||||
|
||||
private updateMessage(message = "") {
|
||||
this.message = message;
|
||||
}
|
||||
// onItemTap(args: ItemEventData) {
|
||||
// const listView = args.object as ListView
|
||||
// console.log('Tapped item', listView.items[args.index])
|
||||
// }
|
||||
}
|
||||
|
||||
189
apps/blakus-nativescript/src/map-view-model.ts
Normal file
189
apps/blakus-nativescript/src/map-view-model.ts
Normal file
@ -0,0 +1,189 @@
|
||||
import { CartoMap, MapEventData, MapMovedEvent, MapReadyEvent } from "@nativescript-community/ui-carto/ui";
|
||||
import { Dialogs, Observable } from "@nativescript/core";
|
||||
import { HTTPTileDataSource } from '@nativescript-community/ui-carto/datasources/http';
|
||||
import { LocalVectorDataSource } from '@nativescript-community/ui-carto/datasources/vector';
|
||||
import { RasterTileLayer } from '@nativescript-community/ui-carto/layers/raster';
|
||||
import { VectorElementEventData, VectorLayer } from '@nativescript-community/ui-carto/layers/vector';
|
||||
import { Marker, MarkerOptions, MarkerStyleBuilder } from '@nativescript-community/ui-carto/vectorelements/marker';
|
||||
import { GenericMapPos, DefaultLatLonKeys } from "@nativescript-community/ui-carto/core";
|
||||
|
||||
function debounce<T extends (...args: any[]) => void>(func: T, delay: number): (...args: Parameters<T>) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return function(...args: Parameters<T>) {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(() => {
|
||||
func(...args);
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export class MapModel extends Observable {
|
||||
|
||||
#map: CartoMap;
|
||||
#localDataSource: LocalVectorDataSource;
|
||||
#vectorLayer: VectorLayer;
|
||||
#markers: Marker<DefaultLatLonKeys>[] = [];
|
||||
|
||||
constructor(map: CartoMap) {
|
||||
super();
|
||||
this.#map = map;
|
||||
this.#map.on(MapReadyEvent, this.onMapReady.bind(this));
|
||||
this.#map.on(MapMovedEvent, this.onMapMoved.bind(this));
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.#map.getOptions();
|
||||
}
|
||||
|
||||
get focusPosition() {
|
||||
return this.#map.getFocusPos();
|
||||
}
|
||||
|
||||
#positionInBounds(position: GenericMapPos<DefaultLatLonKeys>): boolean {
|
||||
return this.#map.getMapBounds().contains(position);
|
||||
}
|
||||
|
||||
#updateMarkerMetaData(marker: Marker<DefaultLatLonKeys>, data: Record<string, string>) {
|
||||
marker.setProperty('metaData', { ...marker.metaData, ...data });
|
||||
// console.log(marker.metaData);
|
||||
}
|
||||
|
||||
#handleElementClick(data: VectorElementEventData<DefaultLatLonKeys>) {
|
||||
|
||||
// TODO: actually filter for marker
|
||||
const marker = data.element as Marker<DefaultLatLonKeys>;
|
||||
|
||||
this.#updateMarkerMetaData(marker, { status: 'active' })
|
||||
|
||||
Dialogs.alert({
|
||||
title: marker.metaData.title,
|
||||
message: marker.metaData.description,
|
||||
okButtonText: 'OK'
|
||||
}).then(() => {
|
||||
this.#updateMarkerMetaData(marker, { status: 'inactive' })
|
||||
});
|
||||
|
||||
this.setPosition(data.elementPos, 200).then(() => {
|
||||
if (this.#map.getZoom() < 17) {
|
||||
this.#map.setZoom(17, data.elementPos, 500);
|
||||
}
|
||||
this.#map.setBearing(0, 500);
|
||||
});
|
||||
}
|
||||
|
||||
redrawMarkers() {
|
||||
const visibleMarkers = this.#markers
|
||||
.filter(marker => this.#positionInBounds(marker.position))
|
||||
.sort((a, b) => parseInt(a.metaData.affinity) - parseInt(b.metaData.affinity))
|
||||
.slice(0, 20);
|
||||
|
||||
// Hide markers that are not in bounds
|
||||
this.#markers
|
||||
.filter(marker => marker.metaData.status !== 'active' && !visibleMarkers.includes(marker))
|
||||
.forEach(marker => {
|
||||
marker.setProperty('visible', false);
|
||||
this.#localDataSource.remove(marker);
|
||||
this.#markers = this.#markers.filter(m => m !== marker);
|
||||
});
|
||||
|
||||
// Add markers that are visible and not already added
|
||||
visibleMarkers.forEach(marker => {
|
||||
if (!marker.metaData.created_at) {
|
||||
console.log('adding', marker.metaData);
|
||||
this.#localDataSource.add(marker);
|
||||
this.#updateMarkerMetaData(marker, { created_at: Date.now().toString() });
|
||||
}
|
||||
|
||||
this.#updateMarkerMetaData(marker, { last_seen_at: Date.now().toString() });
|
||||
marker.setProperty('visible', true);
|
||||
});
|
||||
}
|
||||
|
||||
addMarker({ id, position, detail }: { id: string, position: GenericMapPos, detail: Record<string, any> }) {
|
||||
if (this.#markers.find(({ options : { metaData } }) => id === metaData.id)) return;
|
||||
|
||||
const markerStyleBuilder = new MarkerStyleBuilder({
|
||||
size: 20,
|
||||
color: '#0000ff',
|
||||
clickSize: 50,
|
||||
// anchor: [0.5, 1],
|
||||
// image: 'res://marker',
|
||||
});
|
||||
|
||||
const marker = new Marker({
|
||||
projection: this.#map.projection,
|
||||
position,
|
||||
styleBuilder: markerStyleBuilder,
|
||||
visible: false,
|
||||
metaData: {
|
||||
id,
|
||||
title: detail.title,
|
||||
description: detail.description,
|
||||
affinity: detail.affinity,
|
||||
status: 'inactive'
|
||||
},
|
||||
});
|
||||
|
||||
this.#markers.push(marker);
|
||||
|
||||
}
|
||||
|
||||
async setPosition(position: GenericMapPos, duration = 0) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.#map.setFocusPos(position, duration);
|
||||
setTimeout(() => {
|
||||
resolve(this.#map.getFocusPos());
|
||||
}, duration);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMapMoved = debounce(({data}: MapEventData) => {
|
||||
this.redrawMarkers();
|
||||
this.notify({ eventName: 'mapMoveEnded', object: this });
|
||||
}, 500)
|
||||
|
||||
onMapReady() {
|
||||
|
||||
this.setPosition({ latitude: 56.94964870, longitude: 24.10518640 });
|
||||
|
||||
const dataSource = new HTTPTileDataSource({
|
||||
minZoom: 0,
|
||||
maxZoom: 22,
|
||||
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
httpHeaders: { 'User-Agent': 'Blakus' }
|
||||
});
|
||||
|
||||
const rasterLayer = new RasterTileLayer({
|
||||
dataSource,
|
||||
zoomLevelBias: 1
|
||||
});
|
||||
|
||||
this.#map.addLayer(rasterLayer);
|
||||
|
||||
// source for our stuff
|
||||
this.#localDataSource = new LocalVectorDataSource({
|
||||
projection: this.#map.projection
|
||||
});
|
||||
|
||||
this.#vectorLayer = new VectorLayer({
|
||||
dataSource: this.#localDataSource,
|
||||
visibleZoomRange: [0, 24]
|
||||
})
|
||||
|
||||
this.#map.addLayer(this.#vectorLayer);
|
||||
|
||||
this.#vectorLayer.setVectorElementEventListener({
|
||||
onVectorElementClicked: this.#handleElementClick.bind(this)
|
||||
}, this.#map.projection);
|
||||
|
||||
this.options.setRotatable(false);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user