Merge branch '59-planner' into 'devel'

Resolve "Planner"

Closes #59

See merge request wgp/dougal/software!2
This commit is contained in:
D. Berge
2020-10-09 15:58:06 +00:00
30 changed files with 1303 additions and 17 deletions

View File

@@ -1150,6 +1150,56 @@ refer to that view for more details. Note that the set of missing shots may not
coincide betwen raw and final data, due to edits on the final dataset.';
--
-- Name: planned_lines; Type: TABLE; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE TABLE _SURVEY__TEMPLATE_.planned_lines (
sequence integer NOT NULL,
line integer NOT NULL,
fsp integer NOT NULL,
lsp integer NOT NULL,
ts0 timestamp with time zone NOT NULL,
ts1 timestamp with time zone NOT NULL,
name text NOT NULL,
remarks text DEFAULT ''::text NOT NULL,
meta jsonb DEFAULT '{}'::jsonb NOT NULL,
class character(1) DEFAULT 'V'::bpchar NOT NULL
);
ALTER TABLE _SURVEY__TEMPLATE_.planned_lines OWNER TO postgres;
--
-- Name: planned_lines_summary; Type: VIEW; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE VIEW _SURVEY__TEMPLATE_.planned_lines_summary AS
SELECT pl.sequence,
pl.line,
pl.fsp,
pl.lsp,
pl.ts0,
pl.ts1,
pl.name,
pl.remarks,
pl.meta,
pl.class,
( SELECT count(*) AS count
FROM _SURVEY__TEMPLATE_.preplot_points pp_1
WHERE ((pp_1.line = pl.line) AND (pp_1.class = pl.class) AND (((pp_1.point >= pl.fsp) AND (pp_1.point <= pl.lsp)) OR ((pp_1.point >= pl.lsp) AND (pp_1.point <= pl.fsp))))) AS num_points,
(pl.ts1 - pl.ts0) AS duration,
public.st_distance(pp0.geometry, pp1.geometry) AS length,
((public.st_azimuth(pp0.geometry, pp1.geometry) * (180.0)::double precision) / pi()) AS azimuth,
(public.st_transform(public.st_makeline(pp0.geometry, pp1.geometry), 4326))::jsonb AS geometry
FROM (((_SURVEY__TEMPLATE_.planned_lines pl
JOIN _SURVEY__TEMPLATE_.preplot_points pp0 ON (((pl.line = pp0.line) AND (pl.fsp = pp0.point) AND (pl.class = pp0.class))))
JOIN _SURVEY__TEMPLATE_.preplot_points pp1 ON (((pl.line = pp1.line) AND (pl.lsp = pp1.point) AND (pl.class = pp1.class))))
JOIN _SURVEY__TEMPLATE_.preplot_points pp ON (((pl.line = pp.line) AND (((pp.point >= pl.fsp) AND (pp.point <= pl.fsp)) OR ((pp.point >= pl.fsp) AND (pp.point <= pl.fsp))) AND (pl.class = pp.class))));
ALTER TABLE _SURVEY__TEMPLATE_.planned_lines_summary OWNER TO postgres;
--
-- Name: preplot_lines_summary; Type: VIEW; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
@@ -1593,6 +1643,22 @@ ALTER TABLE ONLY _SURVEY__TEMPLATE_.labels
ADD CONSTRAINT labels_pkey PRIMARY KEY (name);
--
-- Name: planned_lines planned_lines_name_key; Type: CONSTRAINT; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
ALTER TABLE ONLY _SURVEY__TEMPLATE_.planned_lines
ADD CONSTRAINT planned_lines_name_key UNIQUE (name);
--
-- Name: planned_lines planned_lines_pkey; Type: CONSTRAINT; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
ALTER TABLE ONLY _SURVEY__TEMPLATE_.planned_lines
ADD CONSTRAINT planned_lines_pkey PRIMARY KEY (sequence);
--
-- Name: preplot_lines preplot_lines_pkey; Type: CONSTRAINT; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
@@ -1696,6 +1762,13 @@ CREATE TRIGGER final_shots_qc_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TE
CREATE TRIGGER final_shots_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TEMPLATE_.final_shots FOR EACH STATEMENT EXECUTE FUNCTION public.notify('final_shots');
--
-- Name: planned_lines planned_lines_tg; Type: TRIGGER; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
CREATE TRIGGER planned_lines_tg AFTER INSERT OR DELETE OR UPDATE ON _SURVEY__TEMPLATE_.planned_lines FOR EACH STATEMENT EXECUTE FUNCTION public.notify('planned_lines');
--
-- Name: preplot_lines preplot_lines_tg; Type: TRIGGER; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
@@ -1812,6 +1885,30 @@ ALTER TABLE ONLY _SURVEY__TEMPLATE_.final_shots
ADD CONSTRAINT final_shots_hash_fkey FOREIGN KEY (hash, sequence) REFERENCES _SURVEY__TEMPLATE_.final_lines_files(hash, sequence) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: planned_lines planned_lines_line_class_fkey; Type: FK CONSTRAINT; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
ALTER TABLE ONLY _SURVEY__TEMPLATE_.planned_lines
ADD CONSTRAINT planned_lines_line_class_fkey FOREIGN KEY (line, class) REFERENCES _SURVEY__TEMPLATE_.preplot_lines(line, class) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: planned_lines planned_lines_line_fsp_class_fkey; Type: FK CONSTRAINT; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
ALTER TABLE ONLY _SURVEY__TEMPLATE_.planned_lines
ADD CONSTRAINT planned_lines_line_fsp_class_fkey FOREIGN KEY (line, fsp, class) REFERENCES _SURVEY__TEMPLATE_.preplot_points(line, point, class) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: planned_lines planned_lines_line_lsp_class_fkey; Type: FK CONSTRAINT; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--
ALTER TABLE ONLY _SURVEY__TEMPLATE_.planned_lines
ADD CONSTRAINT planned_lines_line_lsp_class_fkey FOREIGN KEY (line, lsp, class) REFERENCES _SURVEY__TEMPLATE_.preplot_points(line, point, class) ON UPDATE CASCADE ON DELETE CASCADE;
--
-- Name: preplot_lines preplot_lines_hash_fkey; Type: FK CONSTRAINT; Schema: _SURVEY__TEMPLATE_; Owner: postgres
--

View File

@@ -6089,6 +6089,23 @@
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.7.1.tgz",
"integrity": "sha512-/xwPEBidtg69Q3HlqPdU3DnrXQOvQU/CCHA1tcDQVzOwm91YMYaILjNp7L4Eaw5Z4sOYdbBz6koWyibppd8Zqw=="
},
"leaflet-arrowheads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/leaflet-arrowheads/-/leaflet-arrowheads-1.2.2.tgz",
"integrity": "sha512-EDk/dbrIu+vPS1Y1JC8ShA6aLTkdnZ5O6yVu1548dcG6eyIt96TonklKz4OvQKEMzZrFwXJUmGqm8th+IKTltA==",
"requires": {
"leaflet": "^1.6.0",
"leaflet-geometryutil": "^0.9.3"
}
},
"leaflet-geometryutil": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/leaflet-geometryutil/-/leaflet-geometryutil-0.9.3.tgz",
"integrity": "sha512-Wi6YvfNx/Xu9q35AEfXpsUXmIFLen/MO+C2qimxHRnjyeyOxBhdcZa6kSiReaOX0cGK7yQInqrzz0dkIqZ8Dpg==",
"requires": {
"leaflet": ">=0.7.0"
}
},
"leaflet-realtime": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/leaflet-realtime/-/leaflet-realtime-2.2.0.tgz",

View File

@@ -11,6 +11,7 @@
"core-js": "^3.6.5",
"jwt-decode": "^3.0.0",
"leaflet": "^1.7.1",
"leaflet-arrowheads": "^1.2.2",
"leaflet-realtime": "^2.2.0",
"leaflet.markercluster": "^1.4.1",
"typeface-roboto": "0.0.75",

View File

@@ -44,6 +44,7 @@ export default {
tabs: [
{ href: "summary", text: "Summary" },
{ href: "lines", text: "Lines" },
{ href: "plan", text: "Plan" },
{ href: "sequences", text: "Sequences" },
{ href: "calendar", text: "Calendar" },
{ href: "log", text: "Log" },

View File

@@ -5,6 +5,7 @@ import Project from '../views/Project.vue'
import ProjectList from '../views/ProjectList.vue'
import ProjectSummary from '../views/ProjectSummary.vue'
import LineList from '../views/LineList.vue'
import Plan from '../views/Plan.vue'
import LineSummary from '../views/LineSummary.vue'
import SequenceList from '../views/SequenceList.vue'
import SequenceSummary from '../views/SequenceSummary.vue'
@@ -78,6 +79,11 @@ Vue.use(VueRouter)
name: "LineList",
component: LineList
},
{
path: "plan/",
name: "Plan",
component: Plan
},
{
path: "lines/:line",
name: "Line",

View File

@@ -28,6 +28,9 @@
<v-list-item-title v-if="contextMenuItem.ntba">Unset NTBA</v-list-item-title>
<v-list-item-title v-else>Set NTBA</v-list-item-title>
</v-list-item>
<v-list-item @click="addToPlan" v-if="!contextMenuItem.ntba">
<v-list-item-title>Add to plan</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
@@ -245,6 +248,23 @@ export default {
})
},
async addToPlan () {
const payload = {
sequence: null,
line: this.contextMenuItem.line,
fsp: this.contextMenuItem.fsp,
lsp: this.contextMenuItem.lsp
}
console.log("Plan", payload);
const url = `/project/${this.$route.params.project}/plan`;
const init = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload
}
await this.api([url, init]);
},
editItem (item, key) {
this.edit = {
line: item.line,

View File

@@ -27,6 +27,7 @@
import L from 'leaflet'
import 'leaflet-realtime'
import 'leaflet.markercluster'
import 'leaflet-arrowheads'
import { mapActions, mapGetters, mapState } from 'vuex';
import ftstamp from '@/lib/FormatTimestamp'
import zoomFitIcon from '@/assets/zoom-fit-best.svg'
@@ -63,6 +64,7 @@ const layers = {
"OpenSeaMap": L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
attribution: 'Map data: &copy; <a href="http://www.openseamap.org">OpenSeaMap</a> contributors'
}),
"Preplots": L.geoJSON(null, {
pointToLayer (point, latlng) {
return L.circle(latlng, {
@@ -84,6 +86,43 @@ const layers = {
layer.bindTooltip(popup, {sticky: true});
},
}),
"Plan": L.geoJSON(null, {
arrowheads: {
size: "8px",
frequency: "200px"
},
style (feature) {
return {
color: "magenta",
opacity: 0.7
}
},
onEachFeature (feature, layer) {
const p = feature.properties;
if (feature.geometry) {
const d = p.duration;
const duration = `${d.days? d.days+" days ":""}${String(d.hours||0).padStart(2, "0")}:${String(d.minutes||0).padStart(2, "0")}`;
const speed = (p.length / (new Date(p.ts1) - new Date(p.ts0))) * 3.6/1.852 * 1000;
const remarks = p.remarks
? "<hr/>"+p.remarks
: "";
const popup = `Planned sequence <b>${p.sequence}</b><br/>
Line <b>${p.line}</b> ${p.name}<br/>
${p.num_points} points<br/>
${Math.round(p.length)} m ${p.azimuth.toFixed(1)}°<br/>
${duration} @ ${speed.toFixed(1)} kt<br/>
<table><tr><td><b>${p.fsp}</b></td><td>@ ${ftstamp(p.ts0)}</td></tr><tr><td><b>${p.lsp}</b></td><td>@ ${ftstamp(p.ts1)}</td></tr></table>${remarks}`;
layer.bindPopup(popup);
layer.bindTooltip(popup, {sticky: true});
}
}
}),
"Raw lines": L.geoJSON(null, {
pointToLayer (point, latlng) {
return L.circle(latlng, {
@@ -123,6 +162,7 @@ const layers = {
}
}
}),
"Final lines": L.geoJSON(null, {
pointToLayer (point, latlng) {
return L.circle(latlng, {
@@ -276,6 +316,12 @@ export default {
: `/project/${this.$route.params.project}/gis/preplot/point?${query.toString()}`;
}
},
{
layer: layers.Plan,
url: (query = "") => {
return `/project/${this.$route.params.project}/plan`;
}
},
{
layer: layers["Raw lines"],
url: (query = "") => {
@@ -397,10 +443,16 @@ export default {
l.layer.abort = new AbortController();
const signal = l.layer.abort.signal;
const init = {
signal,
headers: {
Accept: "application/geo+json"
}
};
// Firing all refresh events asynchronously, which is OK provided
// we don't have hundreds of layers to be refreshed.
this.api([url, {signal}])
this.api([url, init])
.then( (layer) => {
if (!layer) {
return;

View File

@@ -0,0 +1,559 @@
<template>
<v-container fluid>
<v-card>
<v-card-title>
<v-toolbar flat>
<v-toolbar-title>Plan</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="filter"
append-icon="mdi-magnify"
label="Filter"
single-line
clearable
></v-text-field>
</v-toolbar>
</v-card-title>
<v-card-text>
<v-menu
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-data-table
:headers="headers"
:items="items"
:search="filter"
:loading="loading"
:fixed-header="true"
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' : ''"
@click:row="setActiveItem"
@contextmenu:row="contextMenu"
>
<template v-slot:item.sequence="{item, value}">
<v-edit-dialog
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>
</template>
</v-edit-dialog>
</template>
<template v-slot:item.name="{item, value}">
<v-edit-dialog
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>
</template>
<template v-slot:item.fsp="{item, value}">
<v-edit-dialog
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>
</template>
<template v-slot:item.lsp="{item, value}">
<v-edit-dialog
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>
</template>
<template v-slot:item.ts0="{item, value}">
<v-edit-dialog
large
@open="editItem(item, 'ts0', item.ts1.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>
</template>
<template v-slot:item.ts1="{item, value}">
<v-edit-dialog
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>
</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(1) }} °</span>
</template>
<template v-slot:item.remarks="{item}">
<v-text-field v-if="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>
{{item.remarks}}
<v-btn v-if="edit === null"
icon
small
title="Edit"
:disabled="loading"
@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
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>
</template>
<template v-slot:item.lag="{item}">
<v-edit-dialog
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>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
</template>
<style lang="stylus" scoped>
</style>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: "LineList",
components: {
},
data () {
return {
headers: [
{
value: "sequence",
text: "Sequence"
},
{
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: [],
filter: null,
num_lines: null,
activeItem: null,
edit: null, // {sequence, key, value}
queuedReload: false,
plannerConfig: null,
// Context menu stuff
contextMenuShow: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuItem: null
}
},
computed: {
...mapGetters(['loading', 'serverEvent'])
},
watch: {
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 (oldVal.key == "lagAfter") {
// We need to shift the times for every subsequent sequence
const delta = oldVal.value*60*1000 - this.lagAfter(item);
await this.shiftTimesAfter(item, delta);
} else if (oldVal.key == "speed") {
const v = oldVal.value*(1.852/3.6)/1000; // m/s
const ts1 = new Date(item.ts0.valueOf() + item.length / v);
const delta = ts1 - item.ts1;
await this.shiftTimesAfter(item, delta);
await this.saveItem({sequence: item.sequence, key: 'ts1', value: ts1});
} else if (oldVal.key == "sequence") {
await this.shiftSequences(oldVal.value-item.sequence);
} else if (item[oldVal.key] != oldVal.value) {
if (await this.saveItem(oldVal)) {
item[oldVal.key] = oldVal.value;
} else {
this.edit = oldVal;
}
}
}
}
},
async serverEvent (event) {
if (event.channel == "planned_lines" && event.payload.pid == this.$route.params.project) {
if (!this.loading && !this.queuedReload) {
// Do not force a non-cached response if refreshing as a result
// of an event notification. We will assume that the server has
// already had time to update the cache by the time our request
// gets back to it.
this.getPlannedLines();
} else {
this.queuedReload = true;
}
}
},
queuedReload (newVal, oldVal) {
if (newVal && !oldVal && !this.loading) {
this.getPlannedLines();
}
},
loading (newVal, oldVal) {
if (!newVal && oldVal && this.queuedReload) {
this.getPlannedLines();
}
}
},
methods: {
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]);
await this.getPlannedLines();
},
async shiftSequences(delta) {
const lines = delta < 0
? this.items
: [...this.items].reverse(); // We go backwards so as to avoid conflicts.
for (const line of lines) {
const sequence = line.sequence+delta;
const url = `/project/${this.$route.params.project}/plan/${line.sequence}`;
const init = {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: {sequence, name: null} // Setting name to null causes it to be regenerated
}
await this.api([url, init]);
}
},
async shiftTimesAfter(item, delta) {
const pos = this.items.indexOf(item)+1;
if (pos != 0) {
const modifiedLines = this.items.slice(pos);
if (modifiedLines.length) {
modifiedLines.reverse();
for (const line of modifiedLines) {
const ts0 = new Date(line.ts0.valueOf() + delta);
const ts1 = new Date(line.ts1.valueOf() + delta);
const url = `/project/${this.$route.params.project}/plan/${line.sequence}`;
const init = {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: {ts1, ts0}
}
await this.api([url, init]);
}
}
} else {
console.warn("Item", item, "not found");
}
},
editLagAfter (item) {
const pos = this.items.indexOf(item)+1;
if (pos != 0) {
if (pos < this.items.length) {
// Not last item
this.editedItems = this.items.slice(pos);
} else {
}
} else {
console.warn("Item", item, "not found");
}
},
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 getPlannedLines () {
const url = `/project/${this.$route.params.project}/plan`;
this.queuedReload = false;
this.items = await this.api([url]) || [];
for (const item of this.items) {
item.ts0 = new Date(item.ts0);
item.ts1 = new Date(item.ts1);
}
},
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 getSequences () {
const url = `/project/${this.$route.params.project}/sequence`;
this.sequences = await this.api([url]) || [];
},
setActiveItem (item) {
this.activeItem = this.activeItem == item
? null
: item;
},
...mapActions(["api"])
},
async mounted () {
await this.getPlannerConfig();
this.getPlannedLines();
}
}
</script>

View File

@@ -19,18 +19,36 @@
</v-card-title>
<v-card-text>
<v-menu
v-model="contextMenuShow"
:position-x="contextMenuX"
:position-y="contextMenuY"
absolute
offset-y
>
<v-list dense v-if="contextMenuItem">
<v-list-item @click="addToPlan(false)">
<v-list-item-title>Reshoot</v-list-item-title>
</v-list-item>
<v-list-item @click="addToPlan(true)">
<v-list-item-title>Reshoot with overlap</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-data-table
:headers="headers"
:items="items"
item-key="sequence"
:server-items-length="num_rows"
:search="filter"
:custom-filter="customFilter"
:loading="loading"
:fixed-header="true"
show-expand
:item-class="(item) => activeItem == item ? 'blue accent-1 elevation-3' : ''"
@click:row="setActiveItem"
:headers="headers"
:items="items"
item-key="sequence"
:server-items-length="num_rows"
:search="filter"
:custom-filter="customFilter"
:loading="loading"
:fixed-header="true"
show-expand
:item-class="(item) => activeItem == item ? 'blue accent-1 elevation-3' : ''"
@click:row="setActiveItem"
@contextmenu:row="contextMenu"
>
<template v-slot:expanded-item="{ headers, item }">
@@ -243,8 +261,8 @@
<span>{{ Math.round(props.value) }} m</span>
</template>
<template v-slot:item.azimuth="props">
<span>{{ props.value.toFixed(1) }} °</span>
<template v-slot:item.azimuth="{value}">
<span>{{ value.toFixed? value.toFixed(1) : value }} °</span>
</template>
</v-data-table>
@@ -378,7 +396,17 @@ export default {
num_rows: null,
activeItem: null,
edit: null, // {sequence, key, value}
queuedReload: false
queuedReload: false,
// Planner related stuff
preplots: null,
plannerConfig: null,
// Context menu stuff
contextMenuShow: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuItem: null
}
},
@@ -438,6 +466,67 @@ export default {
methods: {
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 getReshootEndpoints (item, overlap) {
const urlPreplot = `/project/${this.$route.params.project}/line`;
const urlPlannerConfig = `/project/${this.$route.params.project}/configuration/planner`;
if (!this.preplots) {
this.preplots = await this.api([urlPreplot]);
}
if (!this.plannerConfig) {
this.plannerConfig = await this.api([urlPlannerConfig]) || {
overlapBefore: 0,
overlapAfter: 0
};
}
const preplot = this.preplots.find(l => l.line == item.line);
const incr = item.fsp <= item.lsp;
const lim0 = incr
? Math.max : Math.min;
const lim1 = incr
? Math.min : Math.max;
const dir = incr ? 1 : -1;
const sp0 = overlap
? lim0((item.fsp_final || item.fsp) - this.plannerConfig.overlapBefore * dir, preplot.fsp)
: lim0(item.fsp_final || item.fsp, preplot.fsp);
const sp1 = overlap
? lim1((item.lsp_final || item.lsp) + this.plannerConfig.overlapAfter * dir, preplot.lsp)
: lim1(item.lsp_final || item.lsp, preplot.lsp);
return {sp0, sp1}
},
async addToPlan (overlap=false) {
const { sp0, sp1 } = await this.getReshootEndpoints(this.contextMenuItem, overlap);
const payload = {
line: this.contextMenuItem.line,
fsp: sp0,
lsp: sp1,
remarks: `Reshoot of sequence ${this.contextMenuItem.sequence}.`
}
console.log("Plan", payload);
const url = `/project/${this.$route.params.project}/plan`;
const init = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload
}
await this.api([url, init]);
},
editItem (item, key) {
this.edit = {
sequence: item.sequence,

View File

@@ -103,6 +103,17 @@ app.map({
// get: [ mw.sequence.get ],
patch: [ mw.sequence.patch ],
},
'/project/:project/plan/': {
get: [ mw.plan.list ],
put: [ mw.plan.put ],
post: [ mw.plan.post ]
},
'/project/:project/plan/:sequence': {
// get: [ mw.plan.get ],
patch: [ mw.plan.patch ],
delete: [ mw.plan.delete ]
},
//
'/project/:project/event/': {
get: [ mw.event.cache.get, mw.event.list, mw.event.cache.save ],

View File

@@ -1,5 +1,6 @@
module.exports = {
event: require('./event'),
plan: require('./plan'),
line: require('./line'),
project: require('./project'),
sequence: require('./sequence'),

View File

@@ -0,0 +1,17 @@
const { plan } = require('../../../lib/db');
module.exports = async function (req, res, next) {
try {
const payload = req.body;
await plan.delete(req.params.project, req.params.sequence);
res.status(201).send();
next();
} catch (err) {
next(err);
}
};

View File

@@ -0,0 +1,8 @@
module.exports = {
list: require('./list'),
get: require('./get'),
post: require('./post'),
put: require('./put'),
patch: require('./patch'),
delete: require('./delete')
};

View File

@@ -0,0 +1,23 @@
const { plan } = require('../../../../lib/db');
const geojson = async function (req, res, next) {
try {
const plans = await plan.list(req.params.project, req.query);
const response = plans.filter(plan => plan.geometry).map(plan => {
const feature = {
type: "Feature",
geometry: plan.geometry,
properties: plan
};
delete feature.properties.geometry;
return feature;
});
res.status(200).send(response);
next();
} catch (err) {
next(err);
}
}
module.exports = geojson;

View File

@@ -0,0 +1,23 @@
const json = require('./json');
const geojson = require('./geojson');
module.exports = async function (req, res, next) {
try {
const handlers = {
"application/json": json,
"application/geo+json": geojson,
};
const mimetype = req.accepts(Object.keys(handlers));
if (mimetype) {
res.set("Content-Type", mimetype);
await handlers[mimetype](req, res, next);
} else {
res.status(406).send();
next();
}
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,14 @@
const { plan } = require('../../../../lib/db');
const json = async function (req, res, next) {
try {
const response = await plan.list(req.params.project, req.query);
res.status(200).send(response);
next();
} catch (err) {
next(err);
}
};
module.exports = json;

View File

@@ -0,0 +1,17 @@
const { plan } = require('../../../lib/db');
module.exports = async function (req, res, next) {
try {
const payload = req.body;
await plan.patch(req.params.project, req.params.sequence, payload);
res.status(201).send();
next();
} catch (err) {
next(err);
}
};

View File

@@ -0,0 +1,17 @@
const { plan } = require('../../../lib/db');
module.exports = async function (req, res, next) {
try {
const payload = req.body;
await plan.post(req.params.project, payload);
res.status(201).send();
next();
} catch (err) {
next(err);
}
};

View File

@@ -0,0 +1,17 @@
const { plan } = require('../../../lib/db');
module.exports = async function (req, res, next) {
try {
const payload = req.body;
await plan.put(req.params.project, payload);
res.status(201).send();
next();
} catch (err) {
next(err);
}
};

View File

@@ -4,6 +4,7 @@ module.exports = {
line: require('./line'),
sequence: require('./sequence'),
event: require('./event'),
plan: require('./plan'),
gis: require('./gis'),
label: require('./label'),
configuration: require('./configuration'),

View File

@@ -0,0 +1,24 @@
const { setSurvey, transaction } = require('../connection');
async function del (projectId, sequence, opts = {}) {
const client = await setSurvey(projectId);
try {
const text = `
DELETE
FROM planned_lines
WHERE sequence = $1;
`;
await client.query(text, [sequence]);
} catch (err) {
throw err;
} finally {
client.release();
}
return;
}
module.exports = del;

View File

View File

@@ -0,0 +1,9 @@
module.exports = {
list: require('./list'),
get: require('./get'),
post: require('./post'),
put: require('./put'),
patch: require('./patch'),
delete: require('./delete')
}

View File

@@ -0,0 +1,102 @@
const configuration = require('../../configuration');
async function getDistance (client, payload) {
const text = `
SELECT ST_Distance(pp0.geometry, pp1.geometry) distance
FROM preplot_points pp0,
preplot_points pp1
WHERE
pp0.line = $1 AND pp1.line = $1
AND pp0.class = $4 AND pp1.class = $4
AND pp0.point = $2 AND pp1.point = $3;
`;
const p = payload;
const res = await client.query(text, [p.line, p.fsp, p.lsp, p.class || "V"]);
if (res.rows.length) {
return res.rows[0].distance;
} // else undefined
}
async function getSequence (client) {
const text = `
SELECT max(sequence)+1 AS sequence
FROM (
SELECT sequence
FROM raw_lines
UNION SELECT sequence
FROM planned_lines
) t;
`;
const res = await client.query(text);
return res.rows[0] && res.rows[0].sequence;
}
async function getTimestamps (client, projectId, payload) {
const defaultLineChangeDuration = (await configuration.get(projectId, "planner/defaultLineChangeDuration") || 30) * 60*1000; // minutes to milliseconds
const defaultAcquisitionSpeed = (await configuration.get(projectId, "planner/defaultAcquisitionSpeed") || 4.8) * 1.852 / 3.6; // Knots to m/s
const distance = await getDistance(client, payload);
const text = `
SELECT * FROM planned_lines;
`;
const res = await client.query(text);
const ts0 = new Date(
(res.rows.length
? res.rows.map(r => r.ts1).reduce( (a, b) => Math.max(a, b) )
: Date.now()
) + defaultLineChangeDuration
);
const ts1 = new Date(ts0.valueOf() + (distance / defaultAcquisitionSpeed)*1000);
return {ts0, ts1};
}
async function getSequencesForLine (client, line) {
const text = `
SELECT * from sequences_summary WHERE line = $1 ORDER BY sequence;
`;
const res = await client.query(text, [line]);
return res.rows;
}
async function getPlanned (client) {
const text = `
SELECT * FROM planned_lines ORDER BY sequence;
`;
const res = await client.query(text);
return res.rows;
}
async function getLineName (client, projectId, payload) {
// FIXME TODO Get line name script from configuration
const planned = await getPlanned(client);
const previous = await getSequencesForLine(client, payload.line);
const attempt = planned.filter(r => r.line == payload.line).concat(previous).length;
const p = payload;
const incr = p.lsp > p.fsp;
const sequence = p.sequence;
const line = p.line;
return `${incr?"1":"2"}0${line}${attempt}${sequence.toString().padStart(3, "0")}S00000`;
}
module.exports = {
getDistance,
getSequence,
getTimestamps,
getSequencesForLine,
getPlanned,
getLineName
};

View File

@@ -0,0 +1,23 @@
const { setSurvey } = require('../connection');
async function list (projectId, opts = {}) {
const client = await setSurvey(projectId);
// const sortFields = [ "line", "length", "azimuth", "fsp", "lsp", "num_points", "incr", "remarks" ];
// const sortKey = opts.sortBy && sortFields.includes(opts.sortBy) && opts.sortBy || "line";
// const sortDir = opts.sortDesc == "true" ? "DESC" : "ASC";
// const offset = Math.abs((opts.page-1)*opts.itemsPerPage) || 0;
// const limit = Math.abs(Number(opts.itemsPerPage)) || null;
const text = `
SELECT *
FROM planned_lines_summary
ORDER BY sequence ASC;
`;
const res = await client.query(text);
client.release();
return res.rows;
}
module.exports = list;

View File

@@ -0,0 +1,47 @@
const { setSurvey, transaction } = require('../connection');
const { getLineName } = require('./lib');
async function patch (projectId, sequence, payload, opts = {}) {
const client = await setSurvey(projectId);
try {
transaction.begin(client);
const text = `
UPDATE planned_lines
SET
sequence = COALESCE($2, sequence),
fsp = COALESCE($3, fsp),
lsp = COALESCE($4, lsp),
ts0 = COALESCE($5, ts0),
ts1 = COALESCE($6, ts1),
name = COALESCE($7, name),
remarks = COALESCE($8, remarks),
meta = COALESCE($9, meta)
WHERE sequence = $1;
`
const p = payload; // For short
const values = [ sequence, p.sequence, p.fsp, p.lsp, p.ts0, p.ts1, p.name, p.remarks, p.meta ];
await client.query(text, values);
// Magic if the name is (strictly) null or empty, we generate a new one
if (p.name === null || p.name === "") {
const text = "SELECT * FROM planned_lines WHERE sequence = $1;";
const res = await client.query(text, [p.sequence||sequence]);
const row = res.rows[0];
const name = await getLineName(client, projectId, row);
await client.query("UPDATE planned_lines SET name = $2 WHERE sequence = $1", [p.sequence||sequence, name]);
}
transaction.commit(client);
} catch (err) {
transaction.rollback(client);
throw err;
} finally {
client.release();
}
}
module.exports = patch;

View File

@@ -0,0 +1,53 @@
const { setSurvey, transaction } = require('../connection');
const lib = require('./lib');
async function post (projectId, payload, opts = {}) {
const client = await setSurvey(projectId);
try {
if (!payload.sequence) {
payload.sequence = await lib.getSequence(client);
}
if (!payload.ts0 || !payload.ts1) {
const ts = await lib.getTimestamps(client, projectId, payload);
if (!payload.ts0) {
payload.ts0 = ts.ts0;
}
if (!payload.ts1) {
payload.ts1 = ts.ts1;
}
}
if (!payload.name) {
payload.name = await lib.getLineName(client, projectId, payload);
}
const p = Object.assign({
remarks: "",
meta: {}
}, payload);
const text = `
INSERT
INTO planned_lines (sequence, line, fsp, lsp, ts0, ts1, name, remarks, meta)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
`;
const values = [ p.sequence, p.line, p.fsp, p.lsp, p.ts0, p.ts1, p.name, p.remarks, p.meta ];
await client.query(text, values);
} catch (err) {
if (err.code && Math.trunc(err.code/1000) == 23) {
// Class 23 — Integrity Constraint Violation
console.error(err);
throw { status: 400, message: "Malformed request" };
} else {
throw err;
}
} finally {
client.release();
}
return;
}
module.exports = post;

View File

@@ -0,0 +1,36 @@
const { setSurvey, transaction } = require('../connection');
async function put (projectId, payload, opts = {}) {
const client = await setSurvey(projectId);
try {
const p = Object.assign({
remarks: "",
meta: {}
}, payload);
const text = `
INSERT
INTO planned_lines (sequence, line, fsp, lsp, ts0, ts1, name, remarks, meta)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);
`;
const values = [ p.sequence, p.line, p.fsp, p.lsp, p.ts0, p.ts1, p.name, p.remarks, p.meta ];
await client.query(text, values);
} catch (err) {
if (err.code && Math.trunc(err.code/1000) == 23) {
// Class 23 — Integrity Constraint Violation
console.error(err);
throw { status: 400, message: "Malformed request" };
} else {
throw err;
}
} finally {
client.release();
}
return;
}
module.exports = put;

View File

@@ -7,6 +7,7 @@ function start (server, pingInterval=30000) {
const channels = [
"realtime", "event", "project",
"preplot_lines", "preplot_points",
"planned_lines",
"raw_lines", "raw_shots",
"final_lines", "final_shots"
];