mirror of
https://gitlab.com/wgp/dougal/software.git
synced 2025-12-06 13:17:08 +00:00
966 lines
29 KiB
Vue
966 lines
29 KiB
Vue
<template>
|
||
<v-container fluid>
|
||
<v-card>
|
||
<v-card-title>
|
||
<v-toolbar flat>
|
||
<v-toolbar-title>Plan</v-toolbar-title>
|
||
|
||
<v-menu v-if="items">
|
||
<template v-slot:activator="{on, attrs}">
|
||
<v-btn class="ml-5" small v-on="on" v-bind="attrs">
|
||
<span class="d-none d-lg-inline">Download as…</span>
|
||
<v-icon right small>mdi-cloud-download</v-icon>
|
||
</v-btn>
|
||
</template>
|
||
|
||
<v-list>
|
||
<v-list-item
|
||
:href="`/api/project/${$route.params.project}/plan/?mime=text%2Fcsv&download`"
|
||
title="Download as a comma-separated values file."
|
||
>CSV</v-list-item>
|
||
<v-list-item
|
||
:href="`/api/project/${$route.params.project}/plan/?mime=application%2Fgeo%2Bjson&download`"
|
||
title="Download as a QGIS-compatible GeoJSON file"
|
||
>GeoJSON</v-list-item>
|
||
<v-list-item
|
||
:href="`/api/project/${$route.params.project}/plan/?mime=application%2Fjson&download`"
|
||
title="Download as a generic JSON file"
|
||
>JSON</v-list-item>
|
||
<v-list-item
|
||
:href="`/api/project/${$route.params.project}/plan/?mime=text%2Fhtml&download`"
|
||
title="Download as an HTML formatted file"
|
||
>HTML</v-list-item>
|
||
<v-list-item
|
||
:href="`/api/project/${$route.params.project}/plan/?mime=application%2Fpdf&download`"
|
||
title="Download as a Portable Document File"
|
||
>PDF</v-list-item>
|
||
</v-list>
|
||
</v-menu>
|
||
|
||
<v-spacer></v-spacer>
|
||
<v-text-field
|
||
v-model="filter"
|
||
append-icon="mdi-magnify"
|
||
label="Filter"
|
||
single-line
|
||
clearable
|
||
hint="Filter by sequence, line, first or last shotpoints, remarks or start/end time"
|
||
></v-text-field>
|
||
</v-toolbar>
|
||
</v-card-title>
|
||
<v-card-text>
|
||
|
||
<v-menu v-if="writeaccess"
|
||
v-model="contextMenuShow"
|
||
:position-x="contextMenuX"
|
||
:position-y="contextMenuY"
|
||
absolute
|
||
offset-y
|
||
>
|
||
<v-list dense v-if="contextMenuItem">
|
||
<v-list-item @click="deletePlannedSequence">
|
||
<v-list-item-icon><v-icon>mdi-delete</v-icon></v-list-item-icon>
|
||
<v-list-item-title class="warning--text">Delete planned sequence</v-list-item-title>
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-menu>
|
||
|
||
<v-card class="mb-5" flat>
|
||
<v-card-title class="text-overline">
|
||
Comments
|
||
<template v-if="writeaccess">
|
||
<v-btn v-if="!editRemarks"
|
||
class="ml-3"
|
||
small
|
||
icon
|
||
title="Edit comments"
|
||
@click="editRemarks=true"
|
||
>
|
||
<v-icon small>mdi-square-edit-outline</v-icon>
|
||
</v-btn>
|
||
|
||
<v-btn v-else
|
||
class="ml-3"
|
||
small
|
||
icon
|
||
title="Save comments"
|
||
@click="saveRemarks"
|
||
>
|
||
<v-icon>mdi-content-save-edit-outline</v-icon>
|
||
</v-btn>
|
||
</template>
|
||
</v-card-title>
|
||
|
||
<v-card-text v-if="editRemarks">
|
||
<v-textarea
|
||
v-model="remarks"
|
||
class="markdown"
|
||
placeholder="Plan comments"
|
||
dense
|
||
auto-grow
|
||
rows="1"
|
||
></v-textarea>
|
||
</v-card-text>
|
||
|
||
<v-card-text v-else v-html="$options.filters.markdown(remarks || '*(nil)*')"></v-card-text>
|
||
|
||
</v-card>
|
||
|
||
<v-data-table
|
||
:headers="headers"
|
||
:items="items"
|
||
:items-per-page.sync="itemsPerPage"
|
||
:server-items-length="sequenceCount"
|
||
item-key="sequence"
|
||
:search="filter"
|
||
:loading="plannedSequencesLoading"
|
||
fixed-header
|
||
no-data-text="No planned lines. Add lines via the context menu from either the Lines or Sequences view."
|
||
:item-class="(item) => (activeItem == item && !edit) ? 'blue accent-1 elevation-3' : ''"
|
||
:footer-props="{showFirstLastPage: true}"
|
||
@click:row="setActiveItem"
|
||
@contextmenu:row="contextMenu"
|
||
>
|
||
|
||
<template v-slot:item.srss="{item}">
|
||
<span style="white-space: nowrap;">
|
||
<v-icon small :title="srssInfo(item)">{{srssIcon(item)}}</v-icon>
|
||
/
|
||
<v-icon small :title="wxInfo(item)" v-if="item.meta.wx">{{wxIcon(item)}}</v-icon>
|
||
</span>
|
||
</template>
|
||
|
||
<template v-slot:item.sequence="{item, value}">
|
||
<v-edit-dialog v-if="writeaccess"
|
||
large
|
||
@open="editItem(item, 'sequence')"
|
||
@save="edit = null"
|
||
@cancel="edit.value = item.sequence; edit = null"
|
||
>
|
||
<span>{{ value }}</span>
|
||
<template v-slot:input>
|
||
<v-text-field v-if="edit"
|
||
type="number"
|
||
v-model.number="edit.value"
|
||
single-line
|
||
>
|
||
</v-text-field>
|
||
<v-checkbox
|
||
v-model="shiftAll"
|
||
class="mt-0"
|
||
label="Shift all planned sequences"
|
||
></v-checkbox>
|
||
</template>
|
||
</v-edit-dialog>
|
||
<span v-else>{{ value }}</span>
|
||
</template>
|
||
|
||
<template v-slot:item.name="{item, value}">
|
||
<v-edit-dialog v-if="writeaccess"
|
||
large
|
||
@open="editItem(item, 'name')"
|
||
@save="edit = null"
|
||
@cancel="edit.value = item.name; edit = null"
|
||
>
|
||
<span>{{ value }}</span>
|
||
<template v-slot:input>
|
||
<v-text-field v-if="edit"
|
||
v-model="edit.value"
|
||
single-line
|
||
>
|
||
</v-text-field>
|
||
</template>
|
||
</v-edit-dialog>
|
||
<span v-else>{{ value }}</span>
|
||
</template>
|
||
|
||
<template v-slot:item.fsp="{item, value}">
|
||
<v-edit-dialog v-if="writeaccess"
|
||
large
|
||
@open="editItem(item, 'fsp')"
|
||
@save="edit = null"
|
||
@cancel="edit.value = item.fsp; edit = null"
|
||
>
|
||
<span>{{ value }}</span>
|
||
<template v-slot:input>
|
||
<v-text-field v-if="edit"
|
||
type="number"
|
||
v-model.number="edit.value"
|
||
single-line
|
||
>
|
||
</v-text-field>
|
||
</template>
|
||
</v-edit-dialog>
|
||
<span v-else>{{ value }}</span>
|
||
</template>
|
||
|
||
<template v-slot:item.lsp="{item, value}">
|
||
<v-edit-dialog v-if="writeaccess"
|
||
large
|
||
@open="editItem(item, 'lsp')"
|
||
@save="edit = null"
|
||
@cancel="edit.value = item.lsp; edit = null"
|
||
>
|
||
<span>{{ value }}</span>
|
||
<template v-slot:input>
|
||
<v-text-field v-if="edit"
|
||
type="number"
|
||
v-model.number="edit.value"
|
||
single-line
|
||
>
|
||
</v-text-field>
|
||
</template>
|
||
</v-edit-dialog>
|
||
<span v-else>{{ value }}</span>
|
||
</template>
|
||
|
||
<template v-slot:item.ts0="{item, value}">
|
||
<v-edit-dialog v-if="writeaccess"
|
||
large
|
||
@open="editItem(item, 'ts0', item.ts0.toISOString())"
|
||
@save="edit = null"
|
||
@cancel="edit.value = item.ts0; edit = null"
|
||
>
|
||
<span>{{ value.toISOString ? value.toISOString().slice(0, 16) : "" }}</span>
|
||
<template v-slot:input>
|
||
<v-text-field v-if="edit"
|
||
type="datetime-local"
|
||
v-model="edit.value"
|
||
single-line
|
||
>
|
||
</v-text-field>
|
||
</template>
|
||
</v-edit-dialog>
|
||
<span v-else>{{ value.toISOString ? value.toISOString().slice(0, 16) : "" }}</span>
|
||
</template>
|
||
|
||
<template v-slot:item.ts1="{item, value}">
|
||
<v-edit-dialog v-if="writeaccess"
|
||
large
|
||
@open="editItem(item, 'ts1', item.ts1.toISOString())"
|
||
@save="edit = null"
|
||
@cancel="edit.value = item.ts1; edit = null"
|
||
>
|
||
<span>{{ value.toISOString ? value.toISOString().slice(0, 16) : "" }}</span>
|
||
<template v-slot:input>
|
||
<v-text-field v-if="edit"
|
||
type="datetime-local"
|
||
v-model="edit.value"
|
||
single-line
|
||
>
|
||
</v-text-field>
|
||
</template>
|
||
</v-edit-dialog>
|
||
<span v-else>{{ value.toISOString ? value.toISOString().slice(0, 16) : "" }}</span>
|
||
</template>
|
||
|
||
<template v-slot:item.length="props">
|
||
<span style="white-space:nowrap;">{{ Math.round(props.value) }} m</span>
|
||
</template>
|
||
|
||
<template v-slot:item.azimuth="props">
|
||
<span style="white-space:nowrap;">{{ props.value.toFixed(2) }} °</span>
|
||
</template>
|
||
|
||
<template v-slot:item.remarks="{item}">
|
||
<v-text-field v-if="writeaccess && edit && edit.sequence == item.sequence && edit.key == 'remarks'"
|
||
type="text"
|
||
v-model="edit.value"
|
||
prepend-icon="mdi-restore"
|
||
append-outer-icon="mdi-content-save-edit-outline"
|
||
clearable
|
||
@click:prepend="edit.value = item.remarks; edit = null"
|
||
@click:append-outer="edit = null"
|
||
>
|
||
</v-text-field>
|
||
<div v-else>
|
||
<span v-html="$options.filters.markdownInline(item.remarks)"></span>
|
||
<v-btn v-if="edit === null && writeaccess"
|
||
icon
|
||
small
|
||
title="Edit"
|
||
:disabled="plannedSequencesLoading"
|
||
@click="editItem(item, 'remarks')"
|
||
>
|
||
<v-icon small>mdi-square-edit-outline</v-icon>
|
||
</v-btn>
|
||
</div>
|
||
|
||
</template>
|
||
|
||
<template v-slot:item.speed="{item}">
|
||
<v-edit-dialog v-if="writeaccess"
|
||
large
|
||
@open="editItem(item, 'speed', knots(item).toFixed(1))"
|
||
@save="edit = null"
|
||
@cancel="edit.value = undefined; edit = null"
|
||
>
|
||
<span style="white-space:nowrap;">{{ knots(item).toFixed(1) }} kt</span>
|
||
<template v-slot:input>
|
||
<v-text-field v-if="edit"
|
||
type="number"
|
||
min="0"
|
||
step="0.1"
|
||
v-model.number="edit.value"
|
||
single-line
|
||
>
|
||
</v-text-field>
|
||
</template>
|
||
</v-edit-dialog>
|
||
<span v-else style="white-space:nowrap;">{{ knots(item).toFixed(1) }} kt</span>
|
||
</template>
|
||
|
||
<template v-slot:item.lag="{item}">
|
||
<v-edit-dialog v-if="writeaccess"
|
||
large
|
||
@open="editItem(item, 'lagAfter', Math.round(lagAfter(item)/(60*1000)))"
|
||
@save="edit = null"
|
||
@cancel="edit.value = undefined; edit = null"
|
||
>
|
||
<span>{{ Math.round(lagAfter(item) / (60*1000)) }} min</span>
|
||
<template v-slot:input>
|
||
<v-text-field v-if="edit"
|
||
type="number"
|
||
min="0"
|
||
v-model="edit.value"
|
||
single-line
|
||
>
|
||
</v-text-field>
|
||
</template>
|
||
</v-edit-dialog>
|
||
<span v-else>{{ Math.round(lagAfter(item) / (60*1000)) }} min</span>
|
||
</template>
|
||
|
||
</v-data-table>
|
||
|
||
</v-card-text>
|
||
</v-card>
|
||
</v-container>
|
||
</template>
|
||
|
||
<style lang="stylus" scoped>
|
||
</style>
|
||
|
||
<script>
|
||
import suncalc from 'suncalc';
|
||
import { mapActions, mapGetters } from 'vuex';
|
||
|
||
export default {
|
||
name: "Plan",
|
||
|
||
components: {
|
||
},
|
||
|
||
data () {
|
||
return {
|
||
headers: [
|
||
{
|
||
value: "sequence",
|
||
text: "Sequence"
|
||
},
|
||
{
|
||
value: "srss",
|
||
text: "SR/SS"
|
||
},
|
||
{
|
||
value: "name",
|
||
text: "Name"
|
||
},
|
||
{
|
||
value: "line",
|
||
text: "Line"
|
||
},
|
||
{
|
||
value: "fsp",
|
||
text: "FSP",
|
||
align: "end"
|
||
},
|
||
{
|
||
value: "lsp",
|
||
text: "LSP",
|
||
align: "end"
|
||
},
|
||
{
|
||
value: "ts0",
|
||
text: "Start"
|
||
},
|
||
{
|
||
value: "ts1",
|
||
text: "End"
|
||
},
|
||
{
|
||
value: "num_points",
|
||
text: "Num. points",
|
||
align: "end"
|
||
},
|
||
{
|
||
value: "length",
|
||
text: "Length",
|
||
align: "end"
|
||
},
|
||
{
|
||
value: "azimuth",
|
||
text: "Azimuth",
|
||
align: "end"
|
||
},
|
||
{
|
||
value: "remarks",
|
||
text: "Remarks"
|
||
},
|
||
{
|
||
value: "speed",
|
||
text: "Speed"
|
||
},
|
||
{
|
||
text: "Line change after",
|
||
value: "lag",
|
||
sortable: false
|
||
}
|
||
],
|
||
items: [],
|
||
remarks: null,
|
||
editRemarks: false,
|
||
filter: null,
|
||
options: {},
|
||
sequenceCount: null,
|
||
activeItem: null,
|
||
edit: null, // {sequence, key, value}
|
||
queuedReload: false,
|
||
itemsPerPage: 25,
|
||
|
||
plannerConfig: null,
|
||
shiftAll: false, // Shift all sequences checkbox
|
||
|
||
// Weather API
|
||
wxData: null,
|
||
weathercode: {
|
||
0: {
|
||
description: "Clear sky",
|
||
icon: "mdi-weather-sunny"
|
||
},
|
||
1: {
|
||
description: "Mainly clear",
|
||
icon: "mdi-weather-sunny"
|
||
},
|
||
2: {
|
||
description: "Partly cloudy",
|
||
icon: "mdi-weather-partly-cloudy"
|
||
},
|
||
3: {
|
||
description: "Overcast",
|
||
icon: "mdi-weather-cloudy"
|
||
},
|
||
45: {
|
||
description: "Fog",
|
||
icon: "mde-weather-fog"
|
||
},
|
||
48: {
|
||
description: "Depositing rime fog",
|
||
icon: "mdi-weather-fog"
|
||
},
|
||
51: {
|
||
description: "Light drizzle",
|
||
icon: "mdi-weather-partly-rainy"
|
||
},
|
||
53: {
|
||
description: "Moderate drizzle",
|
||
icon: "mdi-weather-partly-rainy"
|
||
},
|
||
55: {
|
||
description: "Dense drizzle",
|
||
icon: "mdi-weather-rainy"
|
||
},
|
||
56: {
|
||
description: "Light freezing drizzle",
|
||
icon: "mdi-weather-partly-snowy-rainy"
|
||
},
|
||
57: {
|
||
description: "Freezing drizzle",
|
||
icon: "mdi-weather-partly-snowy-rainy"
|
||
},
|
||
61: {
|
||
description: "Light rain",
|
||
icon: "mdi-weather-rainy"
|
||
},
|
||
63: {
|
||
description: "Moderate rain",
|
||
icon: "mdi-weather-rainy"
|
||
},
|
||
65: {
|
||
description: "Heavy rain",
|
||
icon: "mdi-weather-pouring"
|
||
},
|
||
66: {
|
||
description: "Light freezing rain",
|
||
icon: "mdi-loading"
|
||
},
|
||
67: {
|
||
description: "Freezing rain",
|
||
icon: "mdi-loading"
|
||
},
|
||
71: {
|
||
description: "Light snow",
|
||
icon: "mdi-loading"
|
||
},
|
||
73: {
|
||
description: "Moderate snow",
|
||
icon: "mdi-loading"
|
||
},
|
||
75: {
|
||
description: "Heavy snow",
|
||
icon: "mdi-loading"
|
||
},
|
||
77: {
|
||
description: "Snow grains",
|
||
icon: "mdi-loading"
|
||
},
|
||
80: {
|
||
description: "Light rain showers",
|
||
icon: "mdi-loading"
|
||
},
|
||
81: {
|
||
description: "Moderate rain showers",
|
||
icon: "mdi-loading"
|
||
},
|
||
82: {
|
||
description: "Violent rain showers",
|
||
icon: "mdi-loading"
|
||
},
|
||
85: {
|
||
description: "Light snow showers",
|
||
icon: "mdi-loading"
|
||
},
|
||
86: {
|
||
description: "Snow showers",
|
||
icon: "mdi-loading"
|
||
},
|
||
95: {
|
||
description: "Thunderstorm",
|
||
icon: "mdi-loading"
|
||
},
|
||
96: {
|
||
description: "Hailstorm",
|
||
icon: "mdi-loading"
|
||
},
|
||
99: {
|
||
description: "Heavy hailstorm",
|
||
icon: "mdi-loading"
|
||
},
|
||
},
|
||
|
||
// Context menu stuff
|
||
contextMenuShow: false,
|
||
contextMenuX: 0,
|
||
contextMenuY: 0,
|
||
contextMenuItem: null
|
||
}
|
||
},
|
||
|
||
computed: {
|
||
...mapGetters(['user', 'writeaccess', 'plannedSequencesLoading', 'plannedSequences', 'planRemarks'])
|
||
},
|
||
|
||
watch: {
|
||
|
||
options: {
|
||
handler () {
|
||
this.fetchPlannedSequences();
|
||
},
|
||
deep: true
|
||
},
|
||
|
||
async plannedSequences () {
|
||
await this.fetchPlannedSequences();
|
||
},
|
||
|
||
async edit (newVal, oldVal) {
|
||
if (newVal === null && oldVal !== null) {
|
||
const item = this.items.find(i => i.sequence == oldVal.sequence);
|
||
|
||
// Get around this Vuetify ‘feature’
|
||
// https://github.com/vuetifyjs/vuetify/issues/4144
|
||
if (oldVal.value === null) oldVal.value = "";
|
||
|
||
if (item) {
|
||
if (item[oldVal.key] != oldVal.value) {
|
||
if (oldVal.key == "lagAfter") {
|
||
// Convert from minutes to seconds
|
||
oldVal.value *= 60;
|
||
} else if (oldVal.key == "speed") {
|
||
// Convert knots to metres per second
|
||
oldVal.value = oldVal.value*(1.852/3.6);
|
||
}
|
||
|
||
if (await this.saveItem(oldVal)) {
|
||
item[oldVal.key] = oldVal.value;
|
||
} else {
|
||
this.edit = oldVal;
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
}
|
||
},
|
||
|
||
filter (newVal, oldVal) {
|
||
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
|
||
this.fetchPlannedSequences();
|
||
}
|
||
},
|
||
|
||
itemsPerPage (newVal, oldVal) {
|
||
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`, newVal);
|
||
},
|
||
|
||
user (newVal, oldVal) {
|
||
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
|
||
}
|
||
|
||
},
|
||
|
||
methods: {
|
||
|
||
suntimes (line) {
|
||
const oneday = 86400000;
|
||
|
||
function isDay (srss, ts, lat, lng) {
|
||
if (isNaN(srss.sunriseEnd) || isNaN(srss.sunsetStart)) {
|
||
// Between March and September
|
||
ts = new Date(ts);
|
||
if (ts.getMonth() >= 2 && ts.getMonth() <= 8) {
|
||
// Polar day in the Northern hemisphere, night in the South
|
||
return lat > 0;
|
||
} else {
|
||
return lat < 0;
|
||
}
|
||
} else {
|
||
if (srss.sunriseEnd < ts) {
|
||
if (ts < srss.sunsetStart) {
|
||
return true;
|
||
} else {
|
||
return suncalc.getTimes(new Date(ts.valueOf() + oneday), lat, lng).sunriseEnd < ts;
|
||
}
|
||
} else {
|
||
return ts < suncalc.getTimes(new Date(ts.valueOf() - oneday), lat, lng).sunsetStart;
|
||
}
|
||
}
|
||
}
|
||
|
||
let {ts0, ts1} = line;
|
||
const [ lng0, lat0 ] = line.geometry.coordinates[0];
|
||
const [ lng1, lat1 ] = line.geometry.coordinates[1];
|
||
|
||
if (ts1-ts0 > oneday) {
|
||
console.warn("Cannot provide reliable sunrise / sunset times for lines over 24 hr in this version");
|
||
//return null;
|
||
}
|
||
|
||
const srss0 = suncalc.getTimes(ts0, lat0, lng0);
|
||
const srss1 = suncalc.getTimes(ts1, lat1, lng1);
|
||
|
||
srss0.prevDay = suncalc.getTimes(new Date(ts0.valueOf()-oneday), lat0, lng0);
|
||
srss1.nextDay = suncalc.getTimes(new Date(ts1.valueOf()+oneday), lat1, lng1);
|
||
|
||
srss0.isDay = isDay(srss0, ts0, lat0, lng0);
|
||
srss1.isDay = isDay(srss1, ts1, lat1, lng1);
|
||
|
||
return {
|
||
ts0: srss0,
|
||
ts1: srss1
|
||
};
|
||
},
|
||
|
||
srssIcon (line) {
|
||
const srss = this.suntimes(line);
|
||
const moon = suncalc.getMoonIllumination(line.ts0);
|
||
return srss.ts0.isDay && srss.ts1.isDay
|
||
? 'mdi-weather-sunny'
|
||
: !srss.ts0.isDay && !srss.ts1.isDay
|
||
? moon.phase < 0.05
|
||
? 'mdi-moon-new'
|
||
: moon.phase < 0.25
|
||
? 'mdi-moon-waxing-crescent'
|
||
: moon.phase < 0.45
|
||
? 'mdi-moon-waxing-gibbous'
|
||
: moon.phase < 0.55
|
||
? 'mdi-moon-full'
|
||
: moon.phase < 0.75
|
||
? 'mdi-moon-waning-gibbous'
|
||
: 'mdi-moon-waning-crescent'
|
||
: 'mdi-theme-light-dark';
|
||
},
|
||
|
||
srssMoonPhase (line) {
|
||
const ts = new Date((Number(line.ts0)+Number(line.ts1))/2);
|
||
const moon = suncalc.getMoonIllumination(ts);
|
||
return moon.phase < 0.05
|
||
? 'New moon'
|
||
: moon.phase < 0.25
|
||
? 'Waxing crescent moon'
|
||
: moon.phase < 0.45
|
||
? 'Waxing gibbous moon'
|
||
: moon.phase < 0.55
|
||
? 'Full moon'
|
||
: moon.phase < 0.75
|
||
? 'Waning gibbous moon'
|
||
: 'Waning crescent moon';
|
||
},
|
||
|
||
srssInfo (line) {
|
||
const srss = this.suntimes(line);
|
||
const text = [];
|
||
|
||
try {
|
||
text.push(`Sunset at\t${srss.ts0.prevDay.sunset.toISOString().substr(0, 16)}Z (FSP)`);
|
||
text.push(`Sunrise at\t${srss.ts0.sunrise.toISOString().substr(0, 16)}Z (FSP)`);
|
||
text.push(`Sunset at\t${srss.ts0.sunset.toISOString().substr(0, 16)}Z (FSP)`);
|
||
if (line.ts0.getUTCDate() != line.ts1.getUTCDate()) {
|
||
text.push(`Sunrise at\t${srss.ts1.sunrise.toISOString().substr(0, 16)}Z (LSP)`);
|
||
text.push(`Sunset at\t${srss.ts1.sunset.toISOString().substr(0, 16)}Z (LSP)`);
|
||
}
|
||
text.push(`Sunrise at\t${srss.ts1.nextDay.sunrise.toISOString().substr(0, 16)}Z (LSP)`);
|
||
} catch (err) {
|
||
if (err instanceof RangeError) {
|
||
text.push(srss.ts0.isDay ? "Polar day" : "Polar night");
|
||
} else {
|
||
console.log("ERROR", err);
|
||
}
|
||
}
|
||
|
||
if (!srss.ts0.isDay || !srss.ts1.isDay) {
|
||
text.push(this.srssMoonPhase(line));
|
||
}
|
||
|
||
return text.join("\n");
|
||
},
|
||
|
||
wxInfo (line) {
|
||
|
||
function atm(key) {
|
||
return line.meta?.wx?.atmospheric?.hourly[key];
|
||
}
|
||
|
||
function mar(key) {
|
||
return line.meta?.wx?.marine?.hourly[key];
|
||
}
|
||
|
||
const code = atm("weathercode");
|
||
|
||
const description = this.weathercode[code]?.description ?? `WMO code ${code}`;
|
||
const wind_speed = Math.round(atm("windspeed_10m"));
|
||
const wind_direction = String(Math.round(atm("winddirection_10m"))).padStart(3, "0");
|
||
const pressure = Math.round(atm("surface_pressure"));
|
||
const temperature = Math.round(atm("temperature_2m"));
|
||
const humidity = atm("relativehumidity_2m");
|
||
const precipitation = atm("precipitation");
|
||
const precipitation_probability = atm("precipitation_probability");
|
||
const precipitation_str = precipitation_probability
|
||
? `\nPrecipitation ${precipitation} mm (prob. ${precipitation_probability}%)`
|
||
: ""
|
||
|
||
const wave_height = mar("wave_height").toFixed(1);
|
||
const wave_direction = mar("wave_direction");
|
||
const wave_period = mar("wave_period");
|
||
|
||
return `${description}\n${temperature}° C\n${pressure} hPa\nWind ${wind_speed} kt ${wind_direction}°\nRelative humidity ${humidity}%${precipitation_str}\nWaves ${wave_height} m ${wave_direction}° @ ${wave_period} s`;
|
||
},
|
||
|
||
wxIcon (line) {
|
||
const code = line.meta?.wx?.atmospheric?.hourly?.weathercode;
|
||
|
||
return this.weathercode[code]?.icon ?? "mdi-help";
|
||
|
||
},
|
||
|
||
async wxQuery (line) {
|
||
function midpoint(line) {
|
||
// WARNING Fails if across the antimeridian
|
||
const longitude = (line.geometry.coordinates[0][0] + line.geometry.coordinates[1][0])/2;
|
||
const latitude = (line.geometry.coordinates[0][1] + line.geometry.coordinates[1][1])/2;
|
||
return [ longitude, latitude ];
|
||
}
|
||
|
||
function extract (fcst) {
|
||
const τ = (line.ts0.valueOf() + line.ts1.valueOf()) / 2000;
|
||
const [idx, ε] = fcst?.hourly?.time?.reduce( (acc, cur, idx) => {
|
||
const δ = Math.abs(cur - τ);
|
||
const retval = acc
|
||
? acc[1] < δ
|
||
? acc
|
||
: [ idx, δ ]
|
||
: [ idx, δ ];
|
||
|
||
return retval;
|
||
});
|
||
|
||
if (idx) {
|
||
const hourly = {};
|
||
for (let key in fcst?.hourly) {
|
||
fcst.hourly[key] = fcst.hourly[key][idx];
|
||
}
|
||
}
|
||
|
||
return fcst;
|
||
}
|
||
|
||
async function fetch_atmospheric (opts) {
|
||
const { longitude, latitude, dt0, dt1 } = opts;
|
||
|
||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&hourly=temperature_2m,relativehumidity_2m,precipitation_probability,precipitation,weathercode,pressure_msl,surface_pressure,windspeed_10m,winddirection_10m&daily=uv_index_max&windspeed_unit=kn&timeformat=unixtime&timezone=GMT&start_date=${dt0}&end_date=${dt1}&format=json`;
|
||
const init = {};
|
||
const res = await fetch (url, init);
|
||
if (res?.ok) {
|
||
const data = await res.json();
|
||
|
||
return extract(data);
|
||
}
|
||
}
|
||
|
||
async function fetch_marine (opts) {
|
||
const { longitude, latitude, dt0, dt1 } = opts;
|
||
const url = `https://marine-api.open-meteo.com/v1/marine?latitude=${latitude}&longitude=${longitude}&hourly=wave_height,wave_direction,wave_period&timeformat=unixtime&timezone=GMT&start_date=${dt0}&end_date=${dt1}&format=json`;
|
||
|
||
const init = {};
|
||
const res = await fetch (url, init);
|
||
if (res?.ok) {
|
||
const data = await res.json();
|
||
|
||
return extract(data);
|
||
}
|
||
}
|
||
|
||
if (line) {
|
||
const [ longitude, latitude ] = midpoint(line);
|
||
const dt0 = line.ts0.toISOString().substr(0, 10);
|
||
const dt1 = line.ts1.toISOString().substr(0, 10);
|
||
|
||
return {
|
||
atmospheric: await fetch_atmospheric({longitude, latitude, dt0, dt1}),
|
||
marine: await fetch_marine({longitude, latitude, dt0, dt1})
|
||
};
|
||
}
|
||
},
|
||
|
||
lagAfter (item) {
|
||
const pos = this.items.indexOf(item)+1;
|
||
if (pos != 0) {
|
||
if (pos < this.items.length) {
|
||
const nextItem = this.items[pos];
|
||
return nextItem.ts0 - item.ts1;
|
||
}
|
||
} else {
|
||
console.warn("Item not found in list", item);
|
||
}
|
||
return this.plannerConfig.defaultLineChangeDuration * 60*1000;
|
||
},
|
||
|
||
knots (item) {
|
||
const v = item.length / ((item.ts1-item.ts0)/1000); // m/s
|
||
return v*3.6/1.852;
|
||
},
|
||
|
||
contextMenu (e, {item}) {
|
||
e.preventDefault();
|
||
this.contextMenuShow = false;
|
||
this.contextMenuX = e.clientX;
|
||
this.contextMenuY = e.clientY;
|
||
this.contextMenuItem = item;
|
||
this.$nextTick( () => this.contextMenuShow = true );
|
||
},
|
||
|
||
async deletePlannedSequence () {
|
||
console.log("Delete sequence", this.contextMenuItem.sequence);
|
||
const url = `/project/${this.$route.params.project}/plan/${this.contextMenuItem.sequence}`;
|
||
const init = {method: "DELETE"};
|
||
await this.api([url, init]);
|
||
},
|
||
|
||
editItem (item, key, value) {
|
||
this.edit = {
|
||
sequence: item.sequence,
|
||
key,
|
||
value: value === undefined ? item[key] : value
|
||
}
|
||
},
|
||
|
||
async saveItem (edit) {
|
||
if (!edit) return;
|
||
|
||
try {
|
||
const url = `/project/${this.$route.params.project}/plan/${edit.sequence}`;
|
||
const init = {
|
||
method: "PATCH",
|
||
body: {
|
||
[edit.key]: edit.value
|
||
}
|
||
};
|
||
|
||
let res;
|
||
await this.api([url, init, (e, r) => res = r]);
|
||
return res && res.ok;
|
||
} catch (err) {
|
||
return false;
|
||
}
|
||
},
|
||
|
||
async saveRemarks () {
|
||
const url = `/project/${this.$route.params.project}/info/plan/remarks`;
|
||
let res;
|
||
if (this.remarks) {
|
||
const init = {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "text/plain" },
|
||
body: this.remarks
|
||
};
|
||
await this.api([url, init, (e, r) => res = r]);
|
||
} else {
|
||
const init = {
|
||
method: "DELETE"
|
||
};
|
||
await this.api([url, init, (e, r) => res = r]);
|
||
}
|
||
if (res && res.ok) {
|
||
this.editRemarks = false;
|
||
}
|
||
},
|
||
|
||
async getPlannerConfig () {
|
||
const url = `/project/${this.$route.params.project}/configuration/planner`;
|
||
this.plannerConfig = await this.api([url]) || {
|
||
"overlapAfter": 0,
|
||
"overlapBefore": 0,
|
||
"defaultAcquisitionSpeed": 5,
|
||
"defaultLineChangeDuration": 36
|
||
}
|
||
},
|
||
|
||
async fetchPlannedSequences (opts = {}) {
|
||
const options = {
|
||
text: this.filter,
|
||
...this.options
|
||
};
|
||
const res = await this.getPlannedSequences([this.$route.params.project, options]);
|
||
this.items = res.sequences;
|
||
this.sequenceCount = res.count;
|
||
this.remarks = this.planRemarks;
|
||
},
|
||
|
||
setActiveItem (item) {
|
||
this.activeItem = this.activeItem == item
|
||
? null
|
||
: item;
|
||
},
|
||
|
||
...mapActions(["api", "showSnack", "getPlannedSequences"])
|
||
},
|
||
|
||
async mounted () {
|
||
await this.getPlannerConfig();
|
||
await this.fetchPlannedSequences();
|
||
}
|
||
|
||
}
|
||
|
||
</script>
|