Compare commits
3 Commits
ad57086bcc
...
1c2d245f7c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c2d245f7c | |||
| 3abf69edbd | |||
| 5a2c6d12a7 |
@ -6,22 +6,12 @@ exports.up = function(knex) {
|
|||||||
|
|
||||||
return knex.schema
|
return knex.schema
|
||||||
.createTable('users', function (table) {
|
.createTable('users', function (table) {
|
||||||
table.uuid('id')
|
table.uuid('id').primary().defaultTo(knex.raw(`gen_random_uuid()`));
|
||||||
.primary()
|
table.string('first_name', 255).notNullable();
|
||||||
.defaultTo(knex.raw(`gen_random_uuid()`));
|
table.string('last_name', 255).notNullable();
|
||||||
table.string('first_name', 255)
|
table.string('email', 255).unique().notNullable();
|
||||||
.notNullable();
|
table.timestamps(true, true);
|
||||||
table.string('last_name', 255)
|
|
||||||
.notNullable();
|
|
||||||
table.string('email', 255)
|
|
||||||
.unique()
|
|
||||||
.notNullable();
|
|
||||||
})
|
})
|
||||||
// .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> }
|
* @returns { Promise<void> }
|
||||||
*/
|
*/
|
||||||
exports.down = function(knex) {
|
exports.down = function(knex) {
|
||||||
return knex.schema
|
return knex.schema.dropTable('users');
|
||||||
// .dropTable('products')
|
|
||||||
.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 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
|
* @param { import("knex").Knex } knex
|
||||||
* @returns { Promise<void> }
|
* @returns { Promise<void> }
|
||||||
*/
|
*/
|
||||||
exports.seed = async function(knex) {
|
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').del()
|
||||||
await knex('users').insert([
|
await knex('users').insert([
|
||||||
createUser({
|
createUser({
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { OverpassService } from '../services/overpass';
|
// import { AMENITIES, OverpassService } from '../services/overpass';
|
||||||
import { OverpassQuerySchema, OverpassQueryType } from '../schemas/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) {
|
export default async function (fastify: FastifyInstance) {
|
||||||
|
|
||||||
@ -15,23 +16,76 @@ export default async function (fastify: FastifyInstance) {
|
|||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
title: { type: 'string' },
|
title: { type: 'string' },
|
||||||
amenity: { type: 'string' },
|
amenity: { type: 'string' },
|
||||||
tags: { type: 'object', additionalProperties: true }
|
coords: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
lat: { type: 'number' },
|
||||||
|
lon: { type: 'number' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
affinity: { type: 'number' },
|
||||||
|
// tags: { type: 'object', additionalProperties: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
serverCache: { ttl: 10000 }
|
// serverCache: { ttl: 1 * 60 * 1000 }
|
||||||
}
|
}
|
||||||
}, async function (request, reply) {
|
}, async function (request, reply) {
|
||||||
const {lat, lon} = request.query;
|
const {lat, lon} = request.query;
|
||||||
const elements = await overpassService.getAmenitiesAroundLocation({
|
|
||||||
latlon: { lat, lon },
|
const knownPlaces = await db.select(
|
||||||
amenities: ['restaurant', 'cafe', 'bar', 'pub', 'biergarten', 'fast_food', 'food_court', 'ice_cream']
|
'id',
|
||||||
});
|
'name',
|
||||||
reply.send(elements);
|
'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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
type OverpassResult = {
|
type OverpassResultElement = NodeElement | WayElement | RelationElement;
|
||||||
|
|
||||||
|
type OverpassResult<T = OverpassResultElement> = {
|
||||||
version: number;
|
version: number;
|
||||||
generator: string;
|
generator: string;
|
||||||
osm3s: Record<string, string>;
|
osm3s: Record<string, string>;
|
||||||
elements: (NodeElement | WayElement | RelationElement)[];
|
elements: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type OverpassLocation = {
|
type OverpassLocation = {
|
||||||
@ -38,17 +40,39 @@ type RelationElement = OverpassElement & {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function opq(strings, ...expressions) {
|
type OverpassQuerySettings = {
|
||||||
let query = '[out:json];\n';
|
out?: 'json' | 'xml' // | 'csv' | 'custom' | 'popup'; // TODO: other options would require additional implementation
|
||||||
|
timeout?: number;
|
||||||
|
maxsize?: number;
|
||||||
|
bbox?: [number, number, number, number];
|
||||||
|
date?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < strings.length; i++) {
|
export const AMENITIES = {
|
||||||
query += strings[i];
|
get Sustanance() {
|
||||||
if (i < expressions.length) query += expressions[i];
|
return ['restaurant', 'cafe', 'bar', 'pub', 'biergarten', 'fast_food', 'food_court', 'ice_cream']
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
query += '\nout body;\n>;\nout skel qt;';
|
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) {
|
||||||
return query;
|
let query = [
|
||||||
|
`[out:${out}]`,
|
||||||
|
timeout ? `[timeout:${timeout}]` : '',
|
||||||
|
maxsize ? `[maxsize:${maxsize}]` : '',
|
||||||
|
bbox && bbox.length === 4 ? `[bbox:${bbox.join(',')}]` : '',
|
||||||
|
date ? `[date:${date.toISOString()}]` : ''
|
||||||
|
].join('') + ';';
|
||||||
|
|
||||||
|
for (let i = 0; i < strings.length; i++) {
|
||||||
|
query += strings[i];
|
||||||
|
if (i < expressions.length) query += expressions[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
query += statements.join(';') + ';';
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OverpassService {
|
export class OverpassService {
|
||||||
@ -58,22 +82,36 @@ export class OverpassService {
|
|||||||
this.axios = axios.create({ baseURL });
|
this.axios = axios.create({ baseURL });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAmenitiesAroundLocation({radius = 500, amenities = [], latlon}: {radius?: number, latlon: OverpassLocation, amenities: string[]}) {
|
async getAmenitiesAroundLocation({radius = 500, amenities = [], coords}: {radius?: number, coords: OverpassLocation, amenities: string[]}) {
|
||||||
|
|
||||||
if (!latlon || !latlon.lat || !latlon.lon) throw new Error('Invalid location');
|
if (!coords || !coords.lat || !coords.lon) throw new Error('Invalid location');
|
||||||
|
|
||||||
const { lat, lon } = latlon;
|
const { lat, lon } = coords;
|
||||||
|
|
||||||
|
const opq = overpassQueryTemplate({ out: 'json', timeout: 60 });
|
||||||
|
|
||||||
const query = opq`node(around:${radius}, ${lat}, ${lon})["amenity"~"${amenities.join('|')}"];`;
|
const query = opq`node(around:${radius}, ${lat}, ${lon})["amenity"~"${amenities.join('|')}"];`;
|
||||||
|
|
||||||
const result = await this.overpassQuery(query);
|
const result = await this.overpassQuery<NodeElement>(query);
|
||||||
|
|
||||||
return result.elements
|
const output = result.elements
|
||||||
.map(({ tags }) => ({ title: tags.name, amenity: tags.amenity, tags}))
|
.map(item => {
|
||||||
|
const { tags } = item;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
title: tags.name,
|
||||||
|
amenity: tags.amenity,
|
||||||
|
tags,
|
||||||
|
coords: item.lat && item.lon ? { lat: item.lat, lon: item.lon } : {}
|
||||||
|
}
|
||||||
|
})
|
||||||
.filter(i => amenities.includes(i.amenity)); // query returns some random results. need to look into this
|
.filter(i => amenities.includes(i.amenity)); // query returns some random results. need to look into this
|
||||||
|
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
async overpassQuery(query: string): Promise<OverpassResult> {
|
async overpassQuery<T = OverpassResultElement>(query: string): Promise<OverpassResult<T>> {
|
||||||
return this.getData('interpreter', { data: query });
|
return this.getData('interpreter', { data: query });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,4 +12,5 @@ project.ext {
|
|||||||
|
|
||||||
// useKotlin = true
|
// useKotlin = true
|
||||||
// kotlinVersion = "1.6.0"
|
// kotlinVersion = "1.6.0"
|
||||||
|
cartoMobileVersion = '4.4.7'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
"license": "SEE LICENSE IN <your-license-filename>",
|
"license": "SEE LICENSE IN <your-license-filename>",
|
||||||
"repository": "<fill-your-repository-here>",
|
"repository": "<fill-your-repository-here>",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nativescript-community/ui-carto": "^1.8.18",
|
||||||
"@nativescript/core": "*",
|
"@nativescript/core": "*",
|
||||||
"@nativescript/geolocation": "^9.0.0",
|
"@nativescript/geolocation": "^9.0.0",
|
||||||
"@nativescript/localize": "^5.2.0"
|
"@nativescript/localize": "^5.2.0"
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
import { EventData, Page } from '@nativescript/core';
|
import { EventData, Page } from '@nativescript/core';
|
||||||
import { HelloWorldModel } from './main-view-model';
|
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) {
|
export function navigatingTo(args: EventData) {
|
||||||
const page = <Page>args.object;
|
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>
|
<Page.actionBar>
|
||||||
<ActionBar title="Blakus" icon="" class="action-bar">
|
<ActionBar title="Blakus" icon="" class="action-bar">
|
||||||
</ActionBar>
|
</ActionBar>
|
||||||
</Page.actionBar>
|
</Page.actionBar>
|
||||||
<StackLayout class="p-20">
|
<StackLayout>
|
||||||
<Label text="Tap the button" class="h1 text-center"/>
|
<Label text="Tap the button"/>
|
||||||
<Button text="TAP" tap="{{ onTap }}" />
|
<Button text="TAP" tap="{{ onTap }}" />
|
||||||
<Button text="Location" tap="{{ onLocationTap }}" />
|
<Button text="Location" tap="{{ onLocationTap }}" />
|
||||||
<Label text="{{ message }}" class="h2 text-center" textWrap="true"/>
|
<Label text="{{ message }}" textWrap="true"/>
|
||||||
<ListView items="{{ items }}" itemTap="{{ onItemTap }}">
|
<map:CartoMap id="cartoMap" row="0" zoom="15" width="100%" height="100%"></map:CartoMap>
|
||||||
|
<!-- <ListView items="{{ items }}" itemTap="{{ onItemTap }}">
|
||||||
<ListView.itemTemplate>
|
<ListView.itemTemplate>
|
||||||
<!-- The item template can only have a single root element -->
|
|
||||||
<GridLayout padding="16" columns="20, *, *">
|
<GridLayout padding="16" columns="20, *, *">
|
||||||
<ContentView width="20" height="20" borderRadius="20" backgroundColor="#65adf1" />
|
<ContentView width="20" height="20" borderRadius="20" backgroundColor="#65adf1" />
|
||||||
<Label text="{{ title }}" col="1" textWrap="true" marginLeft="8" />
|
<Label text="{{ title }}" col="1" textWrap="true" marginLeft="8" />
|
||||||
<Label text="{{ L(`amenity.${amenity}`) }}" col="2" />
|
<Label text="{{ L(`amenity.${amenity}`) }}" col="2" />
|
||||||
</GridLayout>
|
</GridLayout>
|
||||||
</ListView.itemTemplate>
|
</ListView.itemTemplate>
|
||||||
</ListView>
|
</ListView> -->
|
||||||
</StackLayout>
|
</StackLayout>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Observable, Http, type ItemEventData, ListView } from '@nativescript/co
|
|||||||
import { DeviceLocation } from './location';
|
import { DeviceLocation } from './location';
|
||||||
import type { Location } from '@nativescript/geolocation';
|
import type { Location } from '@nativescript/geolocation';
|
||||||
import { localize } from '@nativescript/localize';
|
import { localize } from '@nativescript/localize';
|
||||||
|
import { MapModel } from './map-view-model';
|
||||||
|
|
||||||
type ReverseGeocodeResult = {
|
type ReverseGeocodeResult = {
|
||||||
valsts: string;
|
valsts: string;
|
||||||
@ -22,20 +23,15 @@ type ReverseGeocodeResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BlakusItem = {
|
type BlakusItem = {
|
||||||
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
amenity: string;
|
amenity: string;
|
||||||
tags: Record<string, string>;
|
tags: Record<string, string>;
|
||||||
}
|
affinity: number;
|
||||||
|
coords: {
|
||||||
type OverpassResult = {
|
lat: number;
|
||||||
version: number;
|
lon: number;
|
||||||
generator: string;
|
}
|
||||||
osm3s: Record<string, string>;
|
|
||||||
elements: {
|
|
||||||
type: string,
|
|
||||||
id: number,
|
|
||||||
tags: Record<string, string>;
|
|
||||||
}[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function aWait(timeout: number) {
|
async function aWait(timeout: number) {
|
||||||
@ -43,38 +39,26 @@ async function aWait(timeout: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class HelloWorldModel extends Observable {
|
export class HelloWorldModel extends Observable {
|
||||||
private _counter: number;
|
#items: BlakusItem[];
|
||||||
private _message: string;
|
#locationServiceBaseURL = 'https://api.kartes.lv/v3/KVDM_mwwKi/'
|
||||||
private _items: BlakusItem[];
|
#blakusBaseURL = 'https://192.168.0.155:3000';
|
||||||
private _locationServiceBaseURL = 'https://api.kartes.lv/v3/KVDM_mwwKi/'
|
#map: MapModel;
|
||||||
private _blakusBaseURL = 'https://192.168.0.155:3000';
|
|
||||||
|
|
||||||
constructor() {
|
constructor(map: MapModel) {
|
||||||
super();
|
super();
|
||||||
|
this.#map = map;
|
||||||
// Initialize default values.
|
this.#map.on('mapMoveEnded', () => {
|
||||||
this._counter = 42;
|
this.getNearbyAmenities(this.#map.focusPosition);
|
||||||
this.updateMessage();
|
});
|
||||||
}
|
|
||||||
|
|
||||||
get message(): string {
|
|
||||||
return this._message;
|
|
||||||
}
|
|
||||||
|
|
||||||
set message(value: string) {
|
|
||||||
if (this._message !== value) {
|
|
||||||
this._message = value;
|
|
||||||
this.notifyPropertyChange('message', value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get items(): BlakusItem[] {
|
get items(): BlakusItem[] {
|
||||||
return this._items || [];
|
return this.#items || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
set items(value: BlakusItem[]) {
|
set items(value: BlakusItem[]) {
|
||||||
if (this._items !== value) {
|
if (this.#items !== value) {
|
||||||
this._items = value;
|
this.#items = value;
|
||||||
this.notifyPropertyChange('items', value);
|
this.notifyPropertyChange('items', value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,10 +67,8 @@ export class HelloWorldModel extends Observable {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('lat', latitude.toString());
|
params.set('lat', latitude.toString());
|
||||||
params.set('lon', longitude.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);
|
const result = await Http.getJSON<ReverseGeocodeResult>(url);
|
||||||
console.log(result);
|
console.log(result);
|
||||||
|
|
||||||
@ -94,20 +76,33 @@ export class HelloWorldModel extends Observable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getBlakus(params = new URLSearchParams()) {
|
getBlakus(params = new URLSearchParams()) {
|
||||||
const url = new URL(`blakus?${params.toString()}`, this._blakusBaseURL).toString();
|
const url = new URL(`blakus?${params.toString()}`, this.#blakusBaseURL).toString();
|
||||||
console.log('blakus query', url)
|
return Http.getJSON<BlakusItem[]>(url)
|
||||||
return Http.getJSON<OverpassResult["elements"]>(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();
|
const params = new URLSearchParams();
|
||||||
params.set('lat', latitude.toString());
|
params.set('lat', latitude.toFixed(precision).toString());
|
||||||
params.set('lon', longitude.toString());
|
params.set('lon', longitude.toFixed(precision).toString());
|
||||||
|
|
||||||
const result = await this.getBlakus(params);
|
this.items = await this.getBlakus(params);
|
||||||
console.log(result);
|
|
||||||
this.items = result.map(({ tags }) => ({ title: tags.name, amenity: tags.amenity, tags}))
|
this.items.forEach(({ coords, title, amenity, affinity, id }) => {
|
||||||
// .filter(i => amenities.includes(i.amenity));
|
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') {
|
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('union_mode', mode);
|
||||||
params.set('wgs84', '');
|
params.set('wgs84', '');
|
||||||
// params.set('wkt', '');
|
// 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);
|
const result = await Http.getJSON(url);
|
||||||
console.log(url);
|
console.log(url);
|
||||||
@ -128,25 +123,19 @@ export class HelloWorldModel extends Observable {
|
|||||||
|
|
||||||
async onLocationTap() {
|
async onLocationTap() {
|
||||||
const location = await DeviceLocation.getDeviceLocation();
|
const location = await DeviceLocation.getDeviceLocation();
|
||||||
await this.getOverpassAmenities(location);
|
const { latitude, longitude } = location;
|
||||||
}
|
if (this.#map) {
|
||||||
|
this.#map.setPosition({ longitude, latitude }, 500);
|
||||||
onTap() {
|
|
||||||
this._counter--;
|
|
||||||
|
|
||||||
if (this._counter <= 0) {
|
|
||||||
this.updateMessage('Hoorraaay! You unlocked the NativeScript clicker achievement!');
|
|
||||||
} else {
|
|
||||||
this.updateMessage(`${this._counter} taps left`);
|
|
||||||
}
|
}
|
||||||
|
await this.getNearbyAmenities(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
onItemTap(args: ItemEventData) {
|
async onTap() {
|
||||||
const listView = args.object as ListView
|
await this.getNearbyAmenities(this.#map.focusPosition);
|
||||||
console.log('Tapped item', listView.items[args.index])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateMessage(message = "") {
|
// onItemTap(args: ItemEventData) {
|
||||||
this.message = message;
|
// 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