Add map based provider picker

This commit is contained in:
Kacper Donat 2020-05-02 23:33:15 +02:00
parent ec23a41e37
commit a893929cf9
25 changed files with 389 additions and 166 deletions

View File

@ -17,6 +17,8 @@ services:
- ./:/var/www:cached
- ./docker/php/log.conf:/usr/local/etc/php-fpm.d/zz-log.conf
blackfire:
image: blackfire/blackfire
ports: ["8707"]

View File

@ -0,0 +1,42 @@
<main class="d-flex">
<div style="width: 100%">
<l-map :center="{ lat: 52.0194, lon: 19.1451 }" :zoom=7 :options="{ zoomControl: false }" class="map">
<l-vector-layer url="https://api.maptiler.com/maps/bright/style.json?key=8GX5FRUNgk4lB83GZT8Q"
token="not-needed"
attribution='<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a> <a href="https://www.openstreetmap.org/copyright" target="_blank">© OpenStreetMap contributors</a>'
/>
<div class="provider-picker">
<h2 class="provider-picker__heading">Wybierz lokaliację</h2>
<ul class="provider-picker__providers">
<li v-for="provider in providers" :key="provider.id" class="provider-picker__provider">
<a :href="`/${provider.id}`" class="provider">
<ui-icon icon="line-bus" size="2x" />
<div>
<div class="provider__short-name">{{ provider.shortName }}</div>
<div class="provider__name">{{ provider.name }}</div>
</div>
<tooltip v-if="provider.lastUpdate != null">Ostatnia akutalizacja: {{ provider.lastUpdate|moment('YYYY-MM-DD HH:mm') }}</tooltip>
</a>
</li>
</ul>
</div>
<l-marker :lat-lng="provider.location" v-for="provider in providers" :options="{ keyboard: false }" :key="provider.id">
<l-icon>
<div class="map__label-box" tabindex="0">
<a :href="`/${provider.id}`" class="provider">
<ui-icon icon="line-bus" class="map__icon" />
<div>
<div class="provider__short-name">{{ provider.shortName }}</div>
<div class="provider__name">{{ provider.name }}</div>
</div>
</a>
</div>
</l-icon>
</l-marker>
</l-map>
</div>
<portal-target name="popups" multiple/>
</main>

View File

@ -0,0 +1,29 @@
.map__label-box {
@extend .popper;
padding: .5rem;
background: white;
transform-origin: 50% 50%;
transform: translateX(-50%);
min-width: max-content;
font-size: 9pt;
font-weight: bold;
align-items: center;
@include active {
transform: translateX(-50%) scale(1.1);
}
@include flex-with-spacing(.5rem);
}
.map__icon {
font-size: 1.5rem;
}
img.map__icon {
width: 24px;
height: 24px;
}

View File

@ -61,6 +61,20 @@ $grid-gutter-width: $spacer * 2;
}
}
@mixin active {
&:hover, &:active, &:focus, #{&}--active {
@content
}
}
@mixin flex-with-spacing($spacing) {
display: flex;
& > *:not(:last-child) {
margin-right: $spacing;
}
}
@import "common";
@import "stop";
@import "departure";
@ -72,9 +86,12 @@ $grid-gutter-width: $spacer * 2;
@import "favourites";
@import "trip";
@import "dragscroll";
@import "map";
@import "ui/switch";
@import "page/provider-picker";
body {
min-height: 100vh;
display: flex;

View File

@ -0,0 +1,62 @@
.provider__name {
font-size: .9em;
color: $gray-800;
}
.provider__short-name {
font-weight: bold;
}
.provider-picker {
@extend .popper;
padding: 1rem;
margin: 3rem;
}
.provider-picker__heading {
font-size: 1.2rem;
font-weight: bold;
margin-bottom: 1rem;
}
.provider-picker__providers {
list-style: none;
padding: 0;
margin: 0;
}
.provider-picker__provider {
font-size: 1rem;
.provider {
margin: 0 -1rem;
padding: .5rem 1rem;
&:hover {
background: $gray-100;
}
}
}
.provider {
@include flex-with-spacing(.5rem);
align-items: center;
&:hover {
text-decoration: none;
}
}
@include media-breakpoint-down('sm') {
.provider-picker {
position: absolute;
bottom: 0;
left: 0;
right: 0;
margin: 1.5rem;
}
.provider-picker__providers {
max-height: 170px;
}
}

View File

@ -14,7 +14,6 @@ import VueDragscroll from 'vue-dragscroll';
import { Plugin as VueFragment } from 'vue-fragment';
import { Workbox } from "workbox-window";
import { migrate } from "./store/migrations";
import { Component } from "vue-property-decorator";
import * as VueMoment from "vue-moment";
import * as moment from 'moment';
@ -41,6 +40,8 @@ Component.registerHooks(['removed']);
// async dependencies
(async function () {
const { migrate } = await import('./store/migrations');
await migrate("vuex");
const [ components, { default: store } ] = await Promise.all([
@ -50,17 +51,16 @@ Component.registerHooks(['removed']);
import('bootstrap'),
] as const);
const appRoot = document.getElementById('app');
// here goes "public" API
window['app'] = Object.assign({
state: {}
}, window['app'], {
components,
application: new components.Application({ el: '#app' })
application: appRoot ? new components.Application({ el: '#app' }) : new components.PageProviderList({ el: '#provider-picker' }),
});
store.dispatch('messages/update');
store.dispatch('load', window['app'].state);
if ('serviceWorker' in navigator) {
const wb = new Workbox("/service-worker.js");

View File

@ -1,7 +1,7 @@
import Vue from 'vue'
import store from '../store'
import { Component, Watch } from "vue-property-decorator";
import { Mutation, Action } from 'vuex-class'
import { Action, Mutation } from 'vuex-class'
import { Stop } from "../model";
import { DeparturesSettingsState } from "../store/settings/departures";
import { MessagesSettingsState } from "../store/settings/messages";
@ -48,6 +48,9 @@ export class Application extends Vue {
}
created() {
this.$store.dispatch('messages/update');
this.$store.dispatch('load', window['app'].state);
this.initDeparturesRefreshInterval();
this.initMessagesRefreshInterval();
}

View File

@ -11,5 +11,7 @@ export * from './favourites'
export * from './trip'
export * from './ui'
export * from './settings'
export * from "./page"
export { Departures } from "../store";
export { Messages } from "../store";

View File

@ -1,4 +1,4 @@
import { LMap, LTileLayer, LMarker } from 'vue2-leaflet';
import { LControl, LIcon, LMap, LMarker, LPopup, LTileLayer } from 'vue2-leaflet';
import Vue from 'vue';
import * as L from 'leaflet'
@ -48,5 +48,8 @@ Vue.component('LMap', LMap);
Vue.component('LTileLayer', LTileLayer);
Vue.component('LVectorLayer', LVectorLayer);
Vue.component('LMarker', LMarker);
Vue.component('LControl', LControl);
Vue.component('LPopup', LPopup)
Vue.component('LIcon', LIcon);
export { LMap, LTileLayer, LMarker } from 'vue2-leaflet';
export { LMap, LTileLayer, LMarker, LIcon, LControl, LPopup } from 'vue2-leaflet';

View File

@ -0,0 +1 @@
export * from "./providers"

View File

@ -0,0 +1,26 @@
import Vue from 'vue'
import { Component } from 'vue-property-decorator'
import { Provider } from "../../model";
import { Jsonified } from "../../utils";
import * as moment from 'moment';
@Component({
template: require('../../../components/page/providers.html'),
})
export class PageProviderList extends Vue {
private providers: Provider[] = [];
async created() {
const response = await fetch('/api/v1/providers');
const result = await response.json() as Jsonified<Provider>[];
this.providers = result.map<Provider>(provider => {
return {
...provider,
lastUpdate: provider.lastUpdate && moment(provider.lastUpdate)
}
});
}
}
Vue.component('PageProviderList', PageProviderList);

View File

@ -0,0 +1,4 @@
export interface Location {
lat: number,
lng: number,
}

View File

@ -3,3 +3,5 @@ export * from './departure'
export * from './line'
export * from './error'
export * from './identity'
export * from './common'
export * from './provider'

View File

@ -0,0 +1,11 @@
import { Moment } from "moment";
import { Location } from "./common";
export interface Provider {
id: string;
name: string;
shortName: string;
attribution?: string;
lastUpdate?: Moment;
location: Location;
}

View File

@ -1,13 +1,11 @@
import { Line } from "./line";
import { Location } from "./common";
export interface Stop {
id: any;
name: string;
description?: string;
location?: {
lat: number,
lng: number,
};
location?: Location;
onDemand?: boolean;
variant?: string;
}

View File

@ -5,6 +5,7 @@ import { ensureArray } from "../utils";
export interface RootState {
stops: Stop[],
provider: any,
}
export interface SavedState {
@ -13,7 +14,8 @@ export interface SavedState {
}
export const state: RootState = {
stops: []
stops: [],
provider: null,
};
export const mutations: MutationTree<RootState> = {
@ -37,4 +39,4 @@ export const actions: ActionTree<RootState, undefined> = {
version: 1,
stops: state.stops.map(stop => stop.id)
})
};
};

View File

@ -1,3 +1,5 @@
import store from "./store";
export type UrlParams = {
[name: string]: any
}
@ -61,5 +63,5 @@ export default {
tracks: `${base}/stops/{id}/tracks`
},
trip: `${base}/trips/{id}`,
prepare: (url: string, params: UrlParams = { }) => prepare(url, Object.assign({}, { provider: window['data'].provider }, params))
prepare: (url: string, params: UrlParams = { }) => prepare(url, Object.assign({}, { provider: store.state.provider }, params))
}

View File

@ -49,6 +49,12 @@ class Provider implements Fillable, Referable
*/
private $lastUpdate;
/**
* Location of provider centre of interest.
* @var Location
*/
private $location;
public function getId(): string
{
return $this->id;
@ -98,4 +104,14 @@ class Provider implements Fillable, Referable
{
$this->lastUpdate = $lastUpdate;
}
public function getLocation(): Location
{
return $this->location;
}
public function setLocation(Location $location): void
{
$this->location = $location;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Provider\Dummy;
use App\Exception\NotSupportedException;
use App\Model\Location;
use App\Provider\DepartureRepository;
use App\Provider\LineRepository;
use App\Provider\MessageRepository;
@ -78,6 +79,11 @@ class DummyProvider implements Provider
return null;
}
public function getLocation(): Location
{
return new Location(21.4474, 54.7837);
}
public function getTripRepository(): TripRepository
{
throw new NotSupportedException();

View File

@ -2,6 +2,7 @@
namespace App\Provider;
use App\Model\Location;
use Carbon\Carbon;
interface Provider
@ -17,6 +18,7 @@ interface Provider
public function getShortName(): string;
public function getIdentifier(): string;
public function getAttribution(): ?string;
public function getLocation(): Location;
public function getLastUpdate(): ?Carbon;
}

View File

@ -4,6 +4,7 @@
namespace App\Provider\ZtmGdansk;
use App\Entity\ProviderEntity;
use App\Model\Location;
use App\Provider\Database\GenericLineRepository;
use App\Provider\Database\GenericScheduleRepository;
use App\Provider\Database\GenericStopRepository;
@ -13,11 +14,9 @@ use App\Provider\DepartureRepository;
use App\Provider\LineRepository;
use App\Provider\MessageRepository;
use App\Provider\Provider;
use App\Provider\ScheduleRepository;
use App\Provider\StopRepository;
use App\Provider\TrackRepository;
use App\Provider\TripRepository;
use App\Provider\ZtmGdansk\{ZtmGdanskDepartureRepository, ZtmGdanskMessageRepository};
use App\Service\Proxy\ReferenceFactory;
use Carbon\Carbon;
use Doctrine\ORM\EntityManagerInterface;
@ -54,6 +53,11 @@ class ZtmGdanskProvider implements Provider
return '<a href="http://ztm.gda.pl/otwarty_ztm">Otwarte Dane</a> Zarządu Transportu Miejskiego w Gdańsku';
}
public function getLocation(): Location
{
return new Location(18.6466, 54.3520);
}
public function __construct(
EntityManagerInterface $em,
GenericLineRepository $lines,

View File

@ -17,6 +17,7 @@ class ProviderConverter implements Converter
'name' => $entity->getName(),
'attribution' => $entity->getAttribution(),
'lastUpdate' => $entity->getLastUpdate() ? clone $entity->getLastUpdate() : null,
'location' => $entity->getLocation(),
]);
}
@ -25,3 +26,4 @@ class ProviderConverter implements Converter
return $entity instanceof Provider;
}
}

View File

@ -3,146 +3,147 @@
{% block manifest path('webapp_manifest', { provider: provider.identifier }) %}
{% block body %}
<div class="row">
<div class="col-md-8 order-md-last">
<section class="section messages" v-show="messages.count > 0">
<header class="section__title flex">
<h2>
<ui-icon icon="messages" fixed-width class="mr-2"></ui-icon>
Komunikaty <span class="ml-2 badge badge-pill badge-dark">{{ '{{ messages.count }}' }}</span>
</h2>
<button class="btn btn-action flex-space-left" ref="settings-messages" @click="visibility.messages = !visibility.messages">
<tooltip>ustawienia</tooltip>
<ui-icon icon="settings" fixed-width></ui-icon>
</button>
<button class="btn btn-action" @click="updateMessages" ref="btn-messages-refresh">
<tooltip>odśwież</tooltip>
<ui-icon icon="refresh" :spin="messages.state === 'fetching'" fixed-width></ui-icon>
</button>
<button class="btn btn-action" @click="sections.messages = !sections.messages">
<tooltip>
{{ '{{ ' }} sections.messages ? 'zwiń' : 'rozwiń' {{ '}}' }}
<span class="sr-only">sekcję komunikatów</span>
</tooltip>
<ui-icon :icon="sections.messages ? 'chevron-up' : 'chevron-down'" fixed-width></ui-icon>
</button>
<main id="app" class="container not-ready">
<div class="row">
<div class="col-md-8 order-md-last">
<section class="section messages" v-show="messages.count > 0">
<header class="section__title flex">
<h2>
<ui-icon icon="messages" fixed-width class="mr-2"></ui-icon>
Komunikaty <span class="ml-2 badge badge-pill badge-dark">{{ '{{ messages.count }}' }}</span>
</h2>
<button class="btn btn-action flex-space-left" ref="settings-messages" @click="visibility.messages = !visibility.messages">
<tooltip>ustawienia</tooltip>
<ui-icon icon="settings" fixed-width></ui-icon>
</button>
<button class="btn btn-action" @click="updateMessages" ref="btn-messages-refresh">
<tooltip>odśwież</tooltip>
<ui-icon icon="refresh" :spin="messages.state === 'fetching'" fixed-width></ui-icon>
</button>
<button class="btn btn-action" @click="sections.messages = !sections.messages">
<tooltip>
{{ '{{ ' }} sections.messages ? 'zwiń' : 'rozwiń' {{ '}}' }}
<span class="sr-only">sekcję komunikatów</span>
</tooltip>
<ui-icon :icon="sections.messages ? 'chevron-up' : 'chevron-down'" fixed-width></ui-icon>
</button>
<portal to="popups">
<popper reference="settings-messages" v-if="visibility.messages" arrow placement="left-start" @leave="visibility.messages = false">
<settings-messages></settings-messages>
</popper>
</portal>
</header>
<fold :visible="sections.messages">
<messages></messages>
</fold>
</section>
<portal to="popups">
<popper reference="settings-messages" v-if="visibility.messages" arrow placement="left-start" @leave="visibility.messages = false">
<settings-messages></settings-messages>
</popper>
</portal>
</header>
<fold :visible="sections.messages">
<messages></messages>
</fold>
</section>
<section class="section">
<header class="section__title flex">
<h2>
<ui-icon icon="timetable" fixed-width></ui-icon>
<span class="text">Odjazdy</span>
</h2>
<section class="section">
<header class="section__title flex">
<h2>
<ui-icon icon="timetable" fixed-width></ui-icon>
<span class="text">Odjazdy</span>
</h2>
<button class="btn btn-action flex-space-left" ref="settings-departures" @click="visibility.departures = !visibility.departures">
<tooltip>ustawienia</tooltip>
<ui-icon icon="settings" fixed-width></ui-icon>
</button>
<button class="btn btn-action" @click="updateDepartures({ stops })">
<tooltip>odśwież</tooltip>
<ui-icon icon="refresh" :spin="departures.state === 'fetching'" fixed-width></ui-icon>
</button>
<portal to="popups">
<popper reference="settings-departures" v-if="visibility.departures" arrow placement="left-start" @leave="visibility.departures = false">
<settings-departures></settings-departures>
</popper>
</portal>
</header>
<departures :stops="stops" v-if="stops.length > 0"></departures>
<div class="alert alert-info" v-else>
<ui-icon icon="info"></ui-icon>
Wybierz przystanki korzystając z wyszukiwarki poniżej, aby zobaczyć listę odjazdów.
</div>
{% if provider.attribution %}
<div class="attribution">
<button class="btn btn-action flex-space-left" ref="settings-departures" @click="visibility.departures = !visibility.departures">
<tooltip>ustawienia</tooltip>
<ui-icon icon="settings" fixed-width></ui-icon>
</button>
<button class="btn btn-action" @click="updateDepartures({ stops })">
<tooltip>odśwież</tooltip>
<ui-icon icon="refresh" :spin="departures.state === 'fetching'" fixed-width></ui-icon>
</button>
<portal to="popups">
<popper reference="settings-departures" v-if="visibility.departures" arrow placement="left-start" @leave="visibility.departures = false">
<settings-departures></settings-departures>
</popper>
</portal>
</header>
<departures :stops="stops" v-if="stops.length > 0"></departures>
<div class="alert alert-info" v-else>
<ui-icon icon="info"></ui-icon>
Pochodzenie danych: {{ provider.attribution|raw }}
Wybierz przystanki korzystając z wyszukiwarki poniżej, aby zobaczyć listę odjazdów.
</div>
{% endif %}
</section>
</div>
<div class="col-md-4 order-md-first">
<section class="section picker" v-if="stops.length > 0">
<header class="section__title flex">
<h2>
<ui-icon icon="stop" fixed-width></ui-icon>
<span class="text">Przystanki</span>
</h2>
<button class="btn btn-action flex-space-left" @click="clear">
<tooltip>usuń wszystkie</tooltip>
<ui-icon icon="delete" fixed-width></ui-icon>
</button>
</header>
<ul class="picker__stops list-underlined">
<li v-for="stop in stops" :key="stop.id" class="d-flex align-items-center">
<picker-stop :stop="stop" class="flex-grow-1">
<template v-slot:primary-action>
<button @click="remove(stop)" class="btn btn-action">
<tooltip>usuń przystanek</tooltip>
<ui-icon icon="remove-stop"></ui-icon>
</button>
</template>
</picker-stop>
</li>
</ul>
<div class="d-flex mt-2">
<button class="btn btn-action btn-sm flex-space-left" @click="visibility.save = true" ref="save">
<ui-icon icon="favourite" fixed-width></ui-icon>
zapisz jako...
</button>
</div>
<popper reference="save" v-if="visibility.save" arrow tabindex="-1" @leave="visibility.save = false" placement="bottom-end">
<favourites-adder @saved="visibility.save = false"/>
</popper>
</section>
<section class="section picker">
<header class="section__title flex">
<template v-if="visibility.picker === 'search'">
<h2 class="flex-grow-1">
<ui-icon icon="search" fixed-width class="mr-1"></ui-icon>
Wybierz przystanki
{% if provider.attribution %}
<div class="attribution">
<ui-icon icon="info"></ui-icon>
Pochodzenie danych: {{ provider.attribution|raw }}
</div>
{% endif %}
</section>
</div>
<div class="col-md-4 order-md-first">
<section class="section picker" v-if="stops.length > 0">
<header class="section__title flex">
<h2>
<ui-icon icon="stop" fixed-width></ui-icon>
<span class="text">Przystanki</span>
</h2>
<button class="btn btn-action" @click="visibility.picker = 'favourites'">
<tooltip>Zapisane</tooltip>
<ui-icon icon="favourite" fixed-witdth></ui-icon>
<button class="btn btn-action flex-space-left" @click="clear">
<tooltip>usuń wszystkie</tooltip>
<ui-icon icon="delete" fixed-width></ui-icon>
</button>
</template>
<template v-else>
<h2 class="flex-grow-1">
<ui-icon icon="favourite" fixed-width class="mr-1"></ui-icon>
Zapisane
</h2>
<button class="btn btn-action" @click="visibility.picker = 'search'">
<tooltip>Wybierz przystanki</tooltip>
<ui-icon icon="search" fixed-witdth></ui-icon>
</button>
</template>
</header>
<div class="transition-box">
<transition name="fade">
<stop-finder @select="add" :blacklist="stops" v-if="visibility.picker === 'search'"></stop-finder>
<favourites v-else-if="visibility.picker === 'favourites'"></favourites>
</transition>
</div>
</section>
</div>
</div>
</header>
<portal-target name="popups" multiple></portal-target>
<ul class="picker__stops list-underlined">
<li v-for="stop in stops" :key="stop.id" class="d-flex align-items-center">
<picker-stop :stop="stop" class="flex-grow-1">
<template v-slot:primary-action>
<button @click="remove(stop)" class="btn btn-action">
<tooltip>usuń przystanek</tooltip>
<ui-icon icon="remove-stop"></ui-icon>
</button>
</template>
</picker-stop>
</li>
</ul>
<div class="d-flex mt-2">
<button class="btn btn-action btn-sm flex-space-left" @click="visibility.save = true" ref="save">
<ui-icon icon="favourite" fixed-width></ui-icon>
zapisz jako...
</button>
</div>
<popper reference="save" v-if="visibility.save" arrow tabindex="-1" @leave="visibility.save = false" placement="bottom-end">
<favourites-adder @saved="visibility.save = false"/>
</popper>
</section>
<section class="section picker">
<header class="section__title flex">
<template v-if="visibility.picker === 'search'">
<h2 class="flex-grow-1">
<ui-icon icon="search" fixed-width class="mr-1"></ui-icon>
Wybierz przystanki
</h2>
<button class="btn btn-action" @click="visibility.picker = 'favourites'">
<tooltip>Zapisane</tooltip>
<ui-icon icon="favourite" fixed-witdth></ui-icon>
</button>
</template>
<template v-else>
<h2 class="flex-grow-1">
<ui-icon icon="favourite" fixed-width class="mr-1"></ui-icon>
Zapisane
</h2>
<button class="btn btn-action" @click="visibility.picker = 'search'">
<tooltip>Wybierz przystanki</tooltip>
<ui-icon icon="search" fixed-witdth></ui-icon>
</button>
</template>
</header>
<div class="transition-box">
<transition name="fade">
<stop-finder @select="add" :blacklist="stops" v-if="visibility.picker === 'search'"></stop-finder>
<favourites v-else-if="visibility.picker === 'favourites'"></favourites>
</transition>
</div>
</section>
</div>
</div>
<portal-target name="popups" multiple></portal-target>
</main>
{% endblock %}
{% block javascripts %}

View File

@ -33,9 +33,7 @@
{% endif %}
</head>
<body>
<main role="main" class="container not-ready" id="app">
{% block body %}{% endblock %}
</main>
{% block body '' %}
<footer class="container">
{% block footer %}
<span>

View File

@ -1,17 +1,5 @@
{% extends 'base.html.twig' %}
{% block body %}
<div class="alert alert-primary">
<ui-icon icon="info-circle"></ui-icon>
Wybierz źródło danych
</div>
<ul class="list-underlined">
{% for provider in providers %}
<li title="Aktualizacja: {{ provider.lastUpdate ? provider.lastUpdate.format('Y.m.d H:i') : 'live' }}">
<a href="{{ path('app', { provider: provider.identifier }) }}" class="btn btn-block btn-action text-left">
{{ provider.name }}
</a>
</li>
{% endfor %}
</ul>
<main class="d-flex" id="provider-picker"></main>
{% endblock %}