Co-authored-by: sora-ext Co-authored-by: soraefir Reviewed-on: #160
This commit is contained in:
152
src/client/api.ts
Normal file
152
src/client/api.ts
Normal file
@ -0,0 +1,152 @@
|
||||
export const throttle = (func: () => void, wait: number) => {
|
||||
var lastTime = 0;
|
||||
var timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
var lastArgs: any[];
|
||||
|
||||
return function (...args: any[]) {
|
||||
const now = Date.now();
|
||||
lastArgs = args;
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
if (now - lastTime >= wait) {
|
||||
lastTime = now;
|
||||
func.apply(this, lastArgs);
|
||||
} else {
|
||||
timeoutId = setTimeout(
|
||||
() => {
|
||||
lastTime = Date.now();
|
||||
func.apply(this, lastArgs);
|
||||
},
|
||||
wait - (now - lastTime)
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const load = (id: string) =>
|
||||
fetch("/api/" + id)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Error " + res.statusText);
|
||||
return res.json();
|
||||
})
|
||||
.then((res) => {
|
||||
for (let e of res.main) {
|
||||
if (e.date_range) {
|
||||
e.date_range[0] = new Date(e.date_range[0]);
|
||||
e.date_range[1] = new Date(e.date_range[1]);
|
||||
}
|
||||
e.day_title = e.day_title || [];
|
||||
}
|
||||
return res;
|
||||
});
|
||||
|
||||
export const save = (id: string, v: journey) =>
|
||||
fetch("/api/" + id, { method: "post", body: JSON.stringify(v) })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Error " + res.statusText);
|
||||
return res.json();
|
||||
})
|
||||
.then((_res) => {
|
||||
console.log("Saved...");
|
||||
});
|
||||
|
||||
export const query_nominatim = (
|
||||
q: string,
|
||||
bb: any,
|
||||
f: (v: string) => Boolean = () => true
|
||||
) => {
|
||||
if (q.length == 0) return Promise.resolve([])
|
||||
let url = new URL("/api/place/" + q, window.location.origin);
|
||||
url.searchParams.append("id", q);
|
||||
url.searchParams.append("bb", JSON.stringify(bb));
|
||||
return fetch(url)
|
||||
.then((res) => (res.status == 200 ? res.json() : []))
|
||||
.then((res) => res.filter(f));
|
||||
};
|
||||
|
||||
export const query_flight = (q: string) =>
|
||||
fetch("/api/flight/" + q).then((res) => res.json());
|
||||
|
||||
type NominatimResult = {
|
||||
type: string;
|
||||
category: string;
|
||||
display_name: string; // DEBUG ONLY
|
||||
};
|
||||
|
||||
export const is_restauration_type = (e: NominatimResult) =>
|
||||
["restaurant", "cafe", "pub", "bar", "fast_food", "food_court"].indexOf(
|
||||
e.type
|
||||
) != -1;
|
||||
export const is_attraction_type = (e: NominatimResult): boolean =>
|
||||
[
|
||||
"tourism",
|
||||
"leisure",
|
||||
"place",
|
||||
"amenity",
|
||||
// "highway",
|
||||
"historic",
|
||||
"natural",
|
||||
"waterway",
|
||||
].indexOf(e.category) != -1 ||
|
||||
[
|
||||
"place_of_worship",
|
||||
"national_park",
|
||||
"nature_reserve",
|
||||
"protected_area",
|
||||
].indexOf(e.type) != -1 || is_travel_type(e);
|
||||
|
||||
export const is_hotel_type = (e: NominatimResult): boolean =>
|
||||
["hotel", "hostel", "guest_house"].indexOf(e.type) != -1
|
||||
export const is_travel_type = (e: NominatimResult): boolean =>
|
||||
["bus_stop", "tram_stop", "station", , "aerodrome", "parking"].indexOf(e.type) != -1
|
||||
|
||||
|
||||
export const icon_type = (item: string | NominatimResult): string => {
|
||||
if (typeof (item) == "string") {
|
||||
return item
|
||||
}
|
||||
let t = item.type;
|
||||
let c = item.category;
|
||||
let types = {
|
||||
utensils: [
|
||||
"restaurant",
|
||||
"cafe",
|
||||
"pub",
|
||||
"bar",
|
||||
"fast_food",
|
||||
"food_court",
|
||||
],
|
||||
bed: ["hotel", "hostel", "guest_house"],
|
||||
landmark: [
|
||||
"museum",
|
||||
"historic",
|
||||
"place_of_worship",
|
||||
"attraction",
|
||||
"information",
|
||||
"university", "science_park", "theatre", "opera"
|
||||
],
|
||||
mountain: ["peak", "viewpoint"],
|
||||
parking: ["parking"],
|
||||
water: ["water", "river", "lake", "torrent", "aquarium"],
|
||||
building: ["community_center", "locality"],
|
||||
archway: ["bridge"],
|
||||
tree: [
|
||||
"woodland",
|
||||
"shieling",
|
||||
"national_park",
|
||||
"park",
|
||||
"zoo",
|
||||
"garden",
|
||||
"nature_reserve",
|
||||
],
|
||||
"dice-five": ["water_park", "theme_park", "casino"],
|
||||
"": ["?", "neighbourhood", "quarter", "highway"],
|
||||
};
|
||||
|
||||
for (let k in types) {
|
||||
if (types[k].indexOf(t) >= 0 || types[k].indexOf(c) >= 0) return k;
|
||||
}
|
||||
console.log(item.display_name, item.category, item.type);
|
||||
return "question";
|
||||
};
|
4
src/client/main.ts
Normal file
4
src/client/main.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import "./types/ext";
|
||||
import "./types/format";
|
||||
import "./api";
|
||||
import "./old";
|
291
src/client/old.js
Normal file
291
src/client/old.js
Normal file
@ -0,0 +1,291 @@
|
||||
|
||||
import * as api from "./api";
|
||||
import journey_wrapper from "./types/wrapper";
|
||||
import { migrator } from "./types/migration";
|
||||
import { getGeoLine } from "./types/geom";
|
||||
|
||||
Vue.component("l-map", window.Vue2Leaflet.LMap);
|
||||
Vue.component("l-tile-layer", window.Vue2Leaflet.LTileLayer);
|
||||
Vue.component("l-marker", window.Vue2Leaflet.LMarker);
|
||||
Vue.component("l-icon", window.Vue2Leaflet.LIcon);
|
||||
Vue.component("l-popup", window.Vue2Leaflet.LPopup);
|
||||
Vue.component("l-tooltip", window.Vue2Leaflet.LTooltip);
|
||||
Vue.component("l-polyline", window.Vue2Leaflet.LPolyline);
|
||||
Vue.component("l-control-scale", window.Vue2Leaflet.LControlScale);
|
||||
|
||||
const app = new Vue({
|
||||
el: "#app",
|
||||
data: {
|
||||
edit_active: ["view", "short"].indexOf(window.location.pathname.split("/")[1]) == -1,
|
||||
journey: new journey_wrapper(window.location.pathname.split("/").pop() || String.gen_id(16)),
|
||||
map_override: { active: false, elements: [] },
|
||||
query: {
|
||||
type: "", res: [], load: false, sub: false, note: false, drawer: false,
|
||||
},
|
||||
leg_nav: {
|
||||
scrollInterval: null,
|
||||
scrollDir: null
|
||||
},
|
||||
impexp: "",
|
||||
lang: {
|
||||
format: "ddd D MMM",
|
||||
formatLocale: {
|
||||
firstDayOfWeek: 1,
|
||||
},
|
||||
monthBeforeYear: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
start_journey: function () { window.location.href = "/" + this.journey.id },
|
||||
|
||||
compute_bb: function () {
|
||||
if (!this.$refs.map) return undefined
|
||||
const bounds = this.$refs.map.mapObject.getBounds();
|
||||
return [[bounds.getSouthWest().lng, bounds.getSouthWest().lat],
|
||||
[bounds.getNorthEast().lng, bounds.getNorthEast().lat]]
|
||||
},
|
||||
generate_rotation: function (index, list) {
|
||||
if (index < 0 || index >= list.length) return 0;
|
||||
const c0 = list[(index == 0) ? index : (index - 1)]
|
||||
const c1 = list[(index == list.length - 1) ? index : (index + 1)]
|
||||
const brng = Math.atan2(c1[1] - c0[1], c1[0] - c0[0]);
|
||||
return `rotate:${brng - Math.PI / 2}rad`;
|
||||
|
||||
},
|
||||
generate_marker: function (item, fcolor) {
|
||||
return `
|
||||
<div style="position: absolute;top: -30px;left: -6px;">
|
||||
<i class=" fa fa-${api.icon_type(item) || "star"} fa-lg icon-white" style="position: absolute;text-align:center; width:24px;height:24px; line-height:1.5em"></i>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="24" height="36">
|
||||
<circle cx="12" cy="12" r="12" fill="${fcolor || item.color || "blue"}"/>
|
||||
<polygon points="4,12 20,12 12,36" fill="${fcolor || item.color || "blue"}" /></svg>`
|
||||
},
|
||||
generate_icon: function (item, fcolor = "", styling = "", classes = "") {
|
||||
return `<i class="fa fa-${api.icon_type(item) || "star"} fa-2x ${classes}" style="${styling}; color:${fcolor || "white"}; text-align:center; align-content:center;"></i>`;
|
||||
},
|
||||
|
||||
|
||||
import_data: function () {
|
||||
this.journey.data = Object.assign(
|
||||
{},
|
||||
JSON.parse(this.impexp.toDecoded()),
|
||||
);
|
||||
this.journey.data.main.forEach((e) => {
|
||||
if (e.date_range) {
|
||||
e.date_range[0] = new Date(e.date_range[0]);
|
||||
e.date_range[1] = new Date(e.date_range[1]);
|
||||
}
|
||||
});
|
||||
},
|
||||
export_data: function () {
|
||||
this.impexp = JSON.stringify(this.journey.data).toEncoded();
|
||||
},
|
||||
filter_selected: function (list, step) {
|
||||
return list.filter((e) =>
|
||||
step ? e.step == this.journey.sel_day : e.step >= 0,
|
||||
);
|
||||
},
|
||||
filter_unselected: function (list) {
|
||||
return list.filter((e) => e.step == undefined || e.step < 0);
|
||||
},
|
||||
remove_item: function (list, idx) {
|
||||
list[idx].step = -1;
|
||||
list.splice(idx, 1);
|
||||
},
|
||||
log: function (e) {
|
||||
console.log(e);
|
||||
},
|
||||
place_delete: function (f, idx) {
|
||||
switch (f) {
|
||||
case "hotel": return this.journey.leg_get().hotel = null;
|
||||
case "restaurant": return this.journey.leg_get().places.restaurants.splice(idx, 1);
|
||||
case "activities": return this.journey.leg_get().places.activities.splice(idx, 1);
|
||||
case "other": return;
|
||||
case "flight": return this.journey.leg_get().travel.splice(idx, 1);
|
||||
default: return true;
|
||||
}
|
||||
},
|
||||
get_filter: function (f) {
|
||||
switch (f) {
|
||||
case "hotel": return api.is_hotel_type;
|
||||
case "restaurant": return api.is_restauration_type;
|
||||
case "place": return api.is_attraction_type;
|
||||
case "other":
|
||||
default: return () => true;
|
||||
}
|
||||
},
|
||||
|
||||
search_nominatim: function (f) {
|
||||
return (q) => api.query_nominatim(q, this.compute_bb(), this.get_filter(f)).catch((_err) => []).then((r) => {
|
||||
r.forEach((rr) => {
|
||||
rr.latlon = [parseFloat(rr.lat), parseFloat(rr.lon)];
|
||||
rr.sname = rr.display_name.split(",")[0];
|
||||
});
|
||||
r = r.filter(e => {
|
||||
if (this.journey.leg_get().hotel && this.journey.leg_get().hotel.osm_id == e.osm_id) return false;
|
||||
if (this.journey.leg_get().places.restaurants.find(i => i.osm_id == e.osm_id)) return false;
|
||||
if (this.journey.leg_get().places.activities.find(i => i.osm_id == e.osm_id)) return false;
|
||||
return true
|
||||
})
|
||||
this.query.load = false;
|
||||
this.query.res = r;
|
||||
return r
|
||||
});
|
||||
},
|
||||
search_travel: function (f) {
|
||||
return (q) => api.query_flight(q).then((r) => {
|
||||
r.forEach(el => {
|
||||
el.path = getGeoLine(
|
||||
{ lat: el.from_geo.lat, lng: el.from_geo.lon },
|
||||
{ lat: el.to_geo.lat, lng: el.to_geo.lon }, { dist: 2_500_000 }).map(v => [v.lat, v.lng])
|
||||
el.type = "flight";
|
||||
});
|
||||
r = r.filter(e => {
|
||||
if (this.journey.leg_get().travel.find(i => `${i.from}->${i.to}` == `${e.from}->${e.to}`)) return false;
|
||||
return true
|
||||
})
|
||||
this.query.load = false;
|
||||
this.query.res = r;
|
||||
return r;
|
||||
});
|
||||
},
|
||||
|
||||
drawer_hover_item: function (item) {
|
||||
if (item) {
|
||||
this.map_override.active = true
|
||||
if (item.type == 'flight') {
|
||||
this.map_override.elements = [[item.from_geo.lat, item.from_geo.lon], [item.to_geo.lat, item.to_geo.lon]]
|
||||
} else {
|
||||
this.map_override.elements = [[item.lat, item.lon]]
|
||||
}
|
||||
} else {
|
||||
this.map_override.active = false
|
||||
}
|
||||
},
|
||||
|
||||
drawer_click_item: function (item) {
|
||||
const tpe = this.query.type;
|
||||
this.query.res = [];
|
||||
this.query.note = false;
|
||||
this.query.type = null;
|
||||
this.query.drawer = item ? true : false;
|
||||
setTimeout(() => this.$refs.map.mapObject.invalidateSize(), 500);
|
||||
this.query.sub = false;
|
||||
this.drawer_hover_item()
|
||||
if (item) {
|
||||
item.step = -1;
|
||||
switch (tpe) {
|
||||
case 'hotel': return this.journey.leg_get().hotel = item;
|
||||
case 'restaurant': return this.journey.leg_get().places.restaurants.push(item);
|
||||
case 'place': return this.journey.leg_get().places.activities.push(item);
|
||||
case 'other': return;
|
||||
case 'flight': return this.journey.leg_get().travel.push(item);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
search_active: function (q) {
|
||||
const txt = q.target.value
|
||||
this.query.load = true;
|
||||
switch (this.query.type) {
|
||||
case 'hotel': return this.search_hotel(txt);
|
||||
case 'restaurant': return this.search_restaurant(txt);
|
||||
case 'place': return this.search_place(txt);
|
||||
case 'other': return this.search_other(txt);
|
||||
case 'flight': return this.search_flight(txt);
|
||||
}
|
||||
},
|
||||
|
||||
search_enable: function (f) {
|
||||
this.query.drawer = true;
|
||||
setTimeout(() => this.$refs.map.mapObject.invalidateSize(), 500);
|
||||
if (f == "notes") {
|
||||
this.query.note = true;
|
||||
this.query.type = null;
|
||||
const query_in = document.getElementById('query_note')
|
||||
setTimeout(() => query_in.focus(), 500);
|
||||
return;
|
||||
}
|
||||
this.query.note = false;
|
||||
this.query.type = f;
|
||||
const query_in = document.getElementById('query_input')
|
||||
setTimeout(() => query_in.focus(), 500);
|
||||
this.search_active({ target: query_in })
|
||||
},
|
||||
|
||||
|
||||
sideScroll: function (element, direction, speed, step) {
|
||||
this.leg_nav.scrollDir = direction
|
||||
if (direction == 'none') return;
|
||||
this.leg_nav.scrollInterval = setInterval(() => {
|
||||
element.scrollLeft += (direction == 'left') ? -step : step;
|
||||
}, speed);
|
||||
},
|
||||
|
||||
keyboardEvent(e) {
|
||||
if (e.which === 13) {
|
||||
}
|
||||
},
|
||||
nav_mousemove(e) {
|
||||
const c = document.querySelector('.scroll-content')
|
||||
const left = e.pageX - c.getBoundingClientRect().left;
|
||||
const newDir =
|
||||
left < c.offsetWidth * 0.1 ? 'left' :
|
||||
(left > c.offsetWidth * 0.9 ? 'right' : 'none')
|
||||
if (!this.leg_nav.scrollInterval || this.leg_nav.scrollDir != newDir) {
|
||||
if (this.leg_nav.scrollInterval) clearInterval(this.leg_nav.scrollInterval)
|
||||
this.sideScroll(c, newDir, 25, 10);
|
||||
}
|
||||
},
|
||||
nav_mouseleave(e) {
|
||||
clearInterval(this.leg_nav.scrollInterval);
|
||||
this.leg_nav.scrollDir = 'none'
|
||||
this.leg_nav.scrollInterval = null
|
||||
},
|
||||
|
||||
refreshTextAreaHeight(event) {
|
||||
console.log("AAA", event.target.scrollHeight, event.target)
|
||||
event.target.style['height'] = 'auto';
|
||||
event.target.style['height'] = event.target.scrollHeight + 'px';
|
||||
event.target.style['max-height'] = "100%";
|
||||
},
|
||||
},
|
||||
created: function () {
|
||||
this.search_hotel = api.throttle(this.search_nominatim("hotel"), 1000)
|
||||
this.search_restaurant = api.throttle(this.search_nominatim("restaurant"), 1000)
|
||||
this.search_place = api.throttle(this.search_nominatim("place"), 1000)
|
||||
this.save_data = api.throttle(() => {
|
||||
this.impexp = JSON.stringify(this.journey.data).toEncoded();
|
||||
api.save(this.journey.id, this.journey.data);
|
||||
}, 1000);
|
||||
this.search_flight = api.throttle(this.search_travel("flight"), 2000)
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
this.journey.day_prev();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
this.journey.day_next();
|
||||
break;
|
||||
default:
|
||||
console.log(e.key);
|
||||
}
|
||||
});
|
||||
|
||||
api.load(this.journey.id).then((r) => {
|
||||
app.journey.data = migrator(r)
|
||||
});
|
||||
},
|
||||
watch: {
|
||||
journey: {
|
||||
handler: function (ndata, odata) {
|
||||
if (this.edit_active)
|
||||
this.save_data();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
94
src/client/types/ext.ts
Normal file
94
src/client/types/ext.ts
Normal file
@ -0,0 +1,94 @@
|
||||
// DATE EXTENTION
|
||||
declare global {
|
||||
interface Date {
|
||||
toJSONLocal: () => string;
|
||||
toLocal: () => string;
|
||||
}
|
||||
}
|
||||
|
||||
Date.prototype.toJSONLocal = function () {
|
||||
function addZ(n: number): string {
|
||||
return n <= 9 ? `0${n}` : `${n}`;
|
||||
}
|
||||
return [
|
||||
this.getFullYear(),
|
||||
addZ(this.getMonth() + 1),
|
||||
addZ(this.getDate()),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
Date.prototype.toLocal = function () {
|
||||
return [["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][this.getDay()],
|
||||
this.getDate(),
|
||||
[
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
][this.getMonth()]].join(" ")
|
||||
;
|
||||
}
|
||||
|
||||
// ARRAY EXTENTION
|
||||
declare global {
|
||||
interface Array<T> {
|
||||
foldl<B>(f: (x: T, acc: B) => B, acc: B): B;
|
||||
foldr<B>(f: (x: T, acc: B) => B, acc: B): B;
|
||||
}
|
||||
}
|
||||
Array.prototype.foldr = function <T, B>(f: (x: T, acc: B) => B, acc: B): B {
|
||||
return this.reverse().foldl(f, acc);
|
||||
};
|
||||
|
||||
Array.prototype.foldl = function <T, B>(f: (x: T, acc: B) => B, acc: B): B {
|
||||
for (let i = 0; i < this.length; i++) acc = f(this[i], acc);
|
||||
return acc;
|
||||
};
|
||||
|
||||
// STRING EXTENTION
|
||||
declare global {
|
||||
interface String {
|
||||
toEncoded: () => String;
|
||||
toDecoded: () => String;
|
||||
}
|
||||
}
|
||||
|
||||
String.prototype.toEncoded = function () {
|
||||
return window.btoa(encodeURIComponent(Array.from(this as string, (c) => c.charCodeAt(0)).foldl(
|
||||
(e, v) => v + String.fromCharCode(e),
|
||||
"",
|
||||
)))
|
||||
};
|
||||
|
||||
String.prototype.toDecoded = function () {
|
||||
return Array.from(decodeURIComponent(window.atob(this as string)), (c) => c.charCodeAt(0)).foldl(
|
||||
(e, v) => v + String.fromCharCode(e),
|
||||
"",
|
||||
);
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface StringConstructor {
|
||||
gen_id: (l: Number) => String;
|
||||
}
|
||||
}
|
||||
|
||||
String.gen_id = function (length) {
|
||||
const characters =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
return Array.from(Array(length))
|
||||
.map((_v) =>
|
||||
characters.charAt(Math.floor(Math.random() * characters.length)),
|
||||
)
|
||||
.join("");
|
||||
};
|
||||
|
||||
export { };
|
59
src/client/types/format.ts
Normal file
59
src/client/types/format.ts
Normal file
@ -0,0 +1,59 @@
|
||||
|
||||
|
||||
declare global {
|
||||
interface LatLng {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
interface geoloc {
|
||||
latlon: [number, number]
|
||||
notes: string
|
||||
step: -1
|
||||
}
|
||||
|
||||
interface map {
|
||||
zoom: number
|
||||
center: LatLng
|
||||
}
|
||||
|
||||
|
||||
interface leg {
|
||||
title: string
|
||||
day_title: string[]
|
||||
date_range: [Date, Date] | null
|
||||
map: map
|
||||
travel: unknown[]
|
||||
hotel: geoloc | null
|
||||
places: {
|
||||
restaurants: geoloc[]
|
||||
activities: geoloc[]
|
||||
}
|
||||
notes: string
|
||||
}
|
||||
|
||||
interface journey {
|
||||
fmt_ver: number
|
||||
title: string
|
||||
main: leg[]
|
||||
}
|
||||
}
|
||||
|
||||
const leg_template: leg = {
|
||||
title: "",
|
||||
day_title: [],
|
||||
map: { zoom: 2, center: { lng: 0, lat: 0 } },
|
||||
travel: [],
|
||||
hotel: null,
|
||||
places: { restaurants: [], activities: [] },
|
||||
notes: "",
|
||||
date_range: null
|
||||
}
|
||||
const journey_template: journey = {
|
||||
fmt_ver: 1,
|
||||
title: "New Journey",
|
||||
main: [leg_template],
|
||||
}
|
||||
|
||||
|
||||
export { map, geoloc, leg, journey }
|
||||
export { journey_template, leg_template }
|
165
src/client/types/geom.ts
Normal file
165
src/client/types/geom.ts
Normal file
@ -0,0 +1,165 @@
|
||||
const ellipsoid = {
|
||||
a: 6378137,
|
||||
b: 6356752.3142,
|
||||
f: 1 / 298.257223563
|
||||
};
|
||||
|
||||
function mod(n: number, p: number): number {
|
||||
const r = n % p;
|
||||
return r < 0 ? r + p : r;
|
||||
}
|
||||
|
||||
function wrap(degrees: number, max = 360) {
|
||||
if (-max <= degrees && degrees <= max) {
|
||||
return degrees;
|
||||
} else {
|
||||
return mod(degrees + max, 2 * max) - max;
|
||||
}
|
||||
}
|
||||
|
||||
function dist(src: LatLng, dst: LatLng, itr = 100, mit = true): number {
|
||||
const p1 = src,
|
||||
p2 = dst;
|
||||
const φ1 = toRadians(p1.lat),
|
||||
λ1 = toRadians(p1.lng);
|
||||
const φ2 = toRadians(p2.lat),
|
||||
λ2 = toRadians(p2.lng);
|
||||
const π = Math.PI;
|
||||
const ε = Number.EPSILON;
|
||||
|
||||
// allow alternative ellipsoid to be specified
|
||||
const { a, b, f } = ellipsoid;
|
||||
|
||||
const dL = λ2 - λ1; // L = difference in longitude, U = reduced latitude, defined by tan U = (1-f)·tanφ.
|
||||
const tanU1 = (1 - f) * Math.tan(φ1),
|
||||
cosU1 = 1 / Math.sqrt(1 + tanU1 * tanU1),
|
||||
sinU1 = tanU1 * cosU1;
|
||||
const tanU2 = (1 - f) * Math.tan(φ2),
|
||||
cosU2 = 1 / Math.sqrt(1 + tanU2 * tanU2),
|
||||
sinU2 = tanU2 * cosU2;
|
||||
|
||||
const antipodal = Math.abs(dL) > π / 2 || Math.abs(φ2 - φ1) > π / 2;
|
||||
|
||||
let λ = dL,
|
||||
sinλ: number | null = null,
|
||||
cosλ: number | null = null; // λ = difference in longitude on an auxiliary sphere
|
||||
let σ = antipodal ? π : 0,
|
||||
sinσ = 0,
|
||||
cosσ = antipodal ? -1 : 1,
|
||||
sinSqσ: number | null = null; // σ = angular distance P₁ P₂ on the sphere
|
||||
let cos2σₘ = 1; // σₘ = angular distance on the sphere from the equator to the midpoint of the line
|
||||
let sinα: number | null = null,
|
||||
cosSqα = 1; // α = azimuth of the geodesic at the equator
|
||||
let C: number | null = null;
|
||||
|
||||
let λʹ: number | null = null,
|
||||
iterations = 0;
|
||||
do {
|
||||
sinλ = Math.sin(λ);
|
||||
cosλ = Math.cos(λ);
|
||||
sinSqσ =
|
||||
cosU2 * sinλ * (cosU2 * sinλ) +
|
||||
(cosU1 * sinU2 - sinU1 * cosU2 * cosλ) * (cosU1 * sinU2 - sinU1 * cosU2 * cosλ);
|
||||
if (Math.abs(sinSqσ) < ε) {
|
||||
break; // co-incident/antipodal points (falls back on λ/σ = L)
|
||||
}
|
||||
sinσ = Math.sqrt(sinSqσ);
|
||||
cosσ = sinU1 * sinU2 + cosU1 * cosU2 * cosλ;
|
||||
σ = Math.atan2(sinσ, cosσ);
|
||||
sinα = (cosU1 * cosU2 * sinλ) / sinσ;
|
||||
cosSqα = 1 - sinα * sinα;
|
||||
cos2σₘ = cosSqα !== 0 ? cosσ - (2 * sinU1 * sinU2) / cosSqα : 0; // on equatorial line cos²α = 0 (§6)
|
||||
C = (f / 16) * cosSqα * (4 + f * (4 - 3 * cosSqα));
|
||||
λʹ = λ;
|
||||
λ = dL + (1 - C) * f * sinα * (σ + C * sinσ * (cos2σₘ + C * cosσ * (-1 + 2 * cos2σₘ * cos2σₘ)));
|
||||
const iterationCheck = antipodal ? Math.abs(λ) - π : Math.abs(λ);
|
||||
if (iterationCheck > π) {
|
||||
throw new EvalError("λ > π");
|
||||
}
|
||||
} while (Math.abs(λ - λʹ) > 1e-12 && ++iterations < itr);
|
||||
|
||||
if (iterations >= itr) {
|
||||
if (mit) {
|
||||
return dist(
|
||||
src,
|
||||
{ lat: dst.lat, lng: dst.lng - 0.01 },
|
||||
itr,
|
||||
mit
|
||||
);
|
||||
} else {
|
||||
throw new EvalError(`Inverse vincenty formula failed to converge after ${itr} iterations
|
||||
(start=${src.lat}/${src.lng}; dest=${dst.lat}/${dst.lng})`);
|
||||
}
|
||||
}
|
||||
const uSq = (cosSqα * (a * a - b * b)) / (b * b);
|
||||
const A = 1 + (uSq / 16384) * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)));
|
||||
const B = (uSq / 1024) * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)));
|
||||
const Δσ =
|
||||
B *
|
||||
sinσ *
|
||||
(cos2σₘ +
|
||||
(B / 4) *
|
||||
(cosσ * (-1 + 2 * cos2σₘ * cos2σₘ) -
|
||||
(B / 6) * cos2σₘ * (-3 + 4 * sinσ * sinσ) * (-3 + 4 * cos2σₘ * cos2σₘ)));
|
||||
|
||||
const s = b * A * (σ - Δσ); // s = length of the geodesic
|
||||
return s
|
||||
|
||||
}
|
||||
|
||||
function pointDistance(src: LatLng, dst: LatLng): number {
|
||||
return dist(
|
||||
{ lat: src.lat, lng: wrap(src.lng, 180) },
|
||||
{ lat: dst.lat, lng: wrap(dst.lng, 180) }
|
||||
);
|
||||
}
|
||||
|
||||
function toRadians(degree: number): number {
|
||||
return (degree * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function toDegrees(radians: number): number {
|
||||
return (radians * 180) / Math.PI;
|
||||
}
|
||||
|
||||
function midpoint(src: LatLng, dst: LatLng): LatLng {
|
||||
// φm = atan2( sinφ1 + sinφ2, √( (cosφ1 + cosφ2⋅cosΔλ)² + cos²φ2⋅sin²Δλ ) )
|
||||
// λm = λ1 + atan2(cosφ2⋅sinΔλ, cosφ1 + cosφ2⋅cosΔλ)
|
||||
// midpoint is sum of vectors to two points: mathforum.org/library/drmath/view/51822.html
|
||||
|
||||
const φ1 = toRadians(src.lat);
|
||||
const λ1 = toRadians(src.lng);
|
||||
const φ2 = toRadians(dst.lat);
|
||||
const Δλ = toRadians(dst.lng - src.lng);
|
||||
|
||||
// get cartesian coordinates for the two points
|
||||
const A = { x: Math.cos(φ1), y: 0, z: Math.sin(φ1) }; // place point A on prime meridian y=0
|
||||
const B = { x: Math.cos(φ2) * Math.cos(Δλ), y: Math.cos(φ2) * Math.sin(Δλ), z: Math.sin(φ2) };
|
||||
|
||||
// vector to midpoint is sum of vectors to two points (no need to normalise)
|
||||
const C = { x: A.x + B.x, y: A.y + B.y, z: A.z + B.z };
|
||||
|
||||
const φm = Math.atan2(C.z, Math.sqrt(C.x * C.x + C.y * C.y));
|
||||
const λm = λ1 + Math.atan2(C.y, C.x);
|
||||
|
||||
return { lat: toDegrees(φm), lng: toDegrees(λm) };
|
||||
}
|
||||
|
||||
function recursiveMidPoint(src: LatLng, dst: LatLng, opt: { step?: number, dist?: number } = {}, curr = 0): LatLng[] {
|
||||
const geom: LatLng[] = [src, dst];
|
||||
const mp = midpoint(src, dst);
|
||||
const split_step = (opt.step != undefined && (opt.step > 0 || curr == 0))
|
||||
const split_dist = (opt.dist != undefined && (pointDistance(src, dst) > opt.dist || curr == 0))
|
||||
const next_opt = split_step ? { step: (opt.step || 0) - 1 } : { dist: opt.dist };
|
||||
if (split_step || split_dist) {
|
||||
geom.splice(0, 1, ...recursiveMidPoint(src, mp, next_opt, curr + 1));
|
||||
geom.splice(geom.length - 2, 2, ...recursiveMidPoint(mp, dst, next_opt, curr + 1));
|
||||
} else {
|
||||
geom.splice(1, 0, mp);
|
||||
}
|
||||
return geom;
|
||||
}
|
||||
|
||||
export function getGeoLine(src: LatLng, dst: LatLng, opt: { step?: number, dist?: number }) {
|
||||
return recursiveMidPoint(src, dst, opt, 1)
|
||||
}
|
24
src/client/types/migration.ts
Normal file
24
src/client/types/migration.ts
Normal file
@ -0,0 +1,24 @@
|
||||
const FMT_VER_0 = 0
|
||||
const FMT_VER_LATEST = FMT_VER_0
|
||||
|
||||
function migrate_A_to_0(e: journey): journey {
|
||||
e.title = (e as any).name;
|
||||
e.main.forEach((v) => {
|
||||
v.date_range = v.date_range || (v as any).dateRange;
|
||||
v.day_title = v.day_title || (v as any).step_title;
|
||||
v.places.activities = v.places.activities || (v as any).places.places;
|
||||
v.travel = v.travel || [];
|
||||
})
|
||||
console.log(e)
|
||||
return e;
|
||||
}
|
||||
|
||||
export const migrator = (e: journey): journey => {
|
||||
if (e.fmt_ver == FMT_VER_LATEST) return e;
|
||||
switch (e.fmt_ver) {
|
||||
case FMT_VER_0: break; // Update when FMT_VER_1 releases
|
||||
default:
|
||||
return migrate_A_to_0(e)
|
||||
}
|
||||
return e;
|
||||
}
|
144
src/client/types/wrapper.ts
Normal file
144
src/client/types/wrapper.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { journey_template, leg_template } from "./format"
|
||||
|
||||
const date_day_diff = (d0: Date, d1: Date): number =>
|
||||
(d1.getTime() - d0.getTime()) / (1000 * 60 * 60 * 24)
|
||||
|
||||
class journey_wrapper {
|
||||
id: String
|
||||
data: journey = journey_template;
|
||||
sel_leg: number = 0;
|
||||
sel_day: number = 0;
|
||||
|
||||
constructor(id: String) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
leg_first = () => this.data.main[0]
|
||||
leg_last = () => this.data.main[this.leg_count() - 1]
|
||||
leg_count(): number {
|
||||
return this.data.main.length;
|
||||
}
|
||||
leg_len(idx?: number): number {
|
||||
let d = this.leg_get(idx == undefined ? this.sel_leg : idx).date_range;
|
||||
return d ? date_day_diff(d[0], d[1]) + 1 : 1;
|
||||
}
|
||||
add_leg(): void {
|
||||
if (this.data.main == undefined) this.data.main = [];
|
||||
this.data.main.push(leg_template);
|
||||
}
|
||||
rm_leg(idx: number): void {
|
||||
this.data.main.splice(idx, 1);
|
||||
if (this.sel_leg == idx) this.leg_prev();
|
||||
if (this.sel_leg > this.data.main.length - 1) this.leg_next();
|
||||
}
|
||||
tot_len(): number | "?" {
|
||||
if (this.leg_count() == 0) return 0;
|
||||
let lf = this.leg_first(), ll = this.leg_last();
|
||||
if (lf.date_range && ll.date_range) {
|
||||
let d0 = lf.date_range[0]
|
||||
let d1 = ll.date_range[1]
|
||||
return date_day_diff(d0, d1);
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
leg_sel(idx: number): void {
|
||||
this.sel_leg = idx;
|
||||
this.sel_day = 0;
|
||||
}
|
||||
leg_get(idx?: number): leg {
|
||||
return this.data.main[idx != undefined ? idx : this.sel_leg]
|
||||
}
|
||||
leg_next(): void {
|
||||
this.sel_leg = Math.min(this.sel_leg + 1, this.leg_count() - 1);
|
||||
this.sel_day = 0;
|
||||
}
|
||||
leg_prev(): void {
|
||||
this.sel_leg = Math.max(this.sel_leg - 1, 0);
|
||||
this.sel_day = 0;
|
||||
}
|
||||
day_next() {
|
||||
this.sel_day += 1
|
||||
if (this.sel_day > this.leg_len() - 1) {
|
||||
if (this.sel_leg < this.leg_count() - 1) {
|
||||
this.leg_next()
|
||||
this.sel_day = 0;
|
||||
} else {
|
||||
this.sel_day = this.leg_len() - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
day_prev() {
|
||||
this.sel_day -= 1
|
||||
if (this.sel_day < 0) {
|
||||
if (this.sel_leg > 0) {
|
||||
this.leg_prev()
|
||||
this.sel_day = this.leg_len() - 1;
|
||||
} else {
|
||||
this.sel_day = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
date_sel(): string {
|
||||
if (this.sel_day < 0) return "?";
|
||||
let leg = this.leg_get()
|
||||
if (!leg.date_range)
|
||||
return "?";
|
||||
var date = new Date(leg.date_range[0]);
|
||||
date.setDate(date.getDate() + this.sel_day);
|
||||
return date.toLocal();
|
||||
}
|
||||
date_tot() {
|
||||
if (this.leg_count() == 0) return "";
|
||||
let lf = this.leg_first(), ll = this.leg_last();
|
||||
if (lf.date_range && ll.date_range)
|
||||
return `${lf.date_range[0].toLocal()} - ${ll.date_range[1].toLocal()}`;
|
||||
return "?";
|
||||
}
|
||||
|
||||
date_update(idx: number) {
|
||||
let date_range = this.leg_get(idx).date_range;
|
||||
if (!date_range) return;
|
||||
let start_end = [0, 0];
|
||||
let step_len = 0;
|
||||
|
||||
let last_start = date_range[0];
|
||||
for (let i = idx - 1; i >= 0; --i) {
|
||||
step_len = this.leg_len(i) - 1;
|
||||
if (this.leg_get(i).date_range) {
|
||||
start_end = [last_start.getDate() - step_len, last_start.getDate()];
|
||||
} else {
|
||||
this.leg_get(i).date_range = [new Date(), new Date()];
|
||||
start_end = [last_start.getDate() - step_len, last_start.getDate()];
|
||||
}
|
||||
let leg = this.leg_get(i)
|
||||
if (leg.date_range) {
|
||||
leg.date_range[0].setTime(last_start.getTime());
|
||||
leg.date_range[0].setDate(start_end[0]);
|
||||
leg.date_range[1].setTime(last_start.getTime());
|
||||
leg.date_range[1].setDate(start_end[1]);
|
||||
last_start = leg.date_range[0];
|
||||
}
|
||||
}
|
||||
|
||||
let last_end = date_range[1];
|
||||
for (let i = idx + 1; i < this.leg_count(); ++i) {
|
||||
step_len = this.leg_len(i) - 1;
|
||||
if (this.leg_get(i).date_range) {
|
||||
start_end = [last_end.getDate(), last_end.getDate() + step_len];
|
||||
} else {
|
||||
this.leg_get(i).date_range = [new Date(), new Date()];
|
||||
start_end = [last_end.getDate(), last_end.getDate() + step_len];
|
||||
}
|
||||
let leg = this.leg_get(i)
|
||||
if (leg.date_range) {
|
||||
leg.date_range[0].setTime(last_end.getTime());
|
||||
leg.date_range[0].setDate(start_end[0]);
|
||||
leg.date_range[1].setTime(last_end.getTime());
|
||||
leg.date_range[1].setDate(start_end[1]);
|
||||
last_end = leg.date_range[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default journey_wrapper;
|
Reference in New Issue
Block a user