all Data Export API changes until may 8 2026
Some checks failed
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 1m29s
Server Tests / Jest – Integration Tests (push) Failing after 1m57s

This commit is contained in:
Devin Major 2026-05-08 11:48:21 -04:00
parent ea46cbeb02
commit ad7db99f07
52 changed files with 7113 additions and 480 deletions

View File

@ -57,6 +57,11 @@
"glob": "CHANGELOG.md", "glob": "CHANGELOG.md",
"input": "docs", "input": "docs",
"output": "/assets/docs" "output": "/assets/docs"
},
{
"glob": "**/*",
"input": "docs/releases",
"output": "/assets/docs/releases"
} }
], ],
"styles": [ "styles": [

View File

@ -1,8 +0,0 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [revision 1603] - 2026-04-28
- Created the changelog file

File diff suppressed because it is too large Load Diff

View File

@ -4,12 +4,19 @@
"license": "COMMERCIAL", "license": "COMMERCIAL",
"angular-cli": {}, "angular-cli": {},
"scripts": { "scripts": {
"generate-release-manifest": "node scripts/generate-release-manifest.js",
"ng": "ng", "ng": "ng",
"prestart-cert": "npm run generate-release-manifest",
"start-cert": "ng serve --ssl true --sslKey ~/ssl/server.key --sslCert ~/ssl/server.crt --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck", "start-cert": "ng serve --ssl true --sslKey ~/ssl/server.key --sslCert ~/ssl/server.crt --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
"prestart": "npm run generate-release-manifest",
"start": "CHOKIDAR_USEPOLLING=true ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck", "start": "CHOKIDAR_USEPOLLING=true ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck",
"prestart-es": "npm run generate-release-manifest",
"start-es": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=es", "start-es": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=es",
"prestart-pt": "npm run generate-release-manifest",
"start-pt": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=pt", "start-pt": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=pt",
"prebuild": "npm run generate-release-manifest",
"build": "ng build", "build": "ng build",
"prebuild-prep": "npm run generate-release-manifest",
"build-prep": "ng build --aot --localize=false", "build-prep": "ng build --aot --localize=false",
"test": "ng test", "test": "ng test",
"lint": "ng lint", "lint": "ng lint",
@ -22,7 +29,9 @@
"sync-i18n": "npm run build-prep && npm run i18n-extract && npm run i18n-merge", "sync-i18n": "npm run build-prep && npm run i18n-extract && npm run i18n-merge",
"sync-i18n-w": "npm run build-prep && npm run i18n-extract-w && npm run i18n-merge-w", "sync-i18n-w": "npm run build-prep && npm run i18n-extract-w && npm run i18n-merge-w",
"pre-translate": "npx translation start && npm run sync-i18n && npx translation translate && npx translation cleanup", "pre-translate": "npx translation start && npm run sync-i18n && npx translation translate && npx translation cleanup",
"prebuild-prod": "npm run generate-release-manifest",
"build-prod": "ng build --prod --localize && cp -R dist/en/* dist/ && rm -R dist/en", "build-prod": "ng build --prod --localize && cp -R dist/en/* dist/ && rm -R dist/en",
"prebuild-prod-window": "npm run generate-release-manifest",
"build-prod-window": "ng build --prod --localize && xcopy /E /Y dist\\en\\* dist\\ && rmdir /S /Q dist\\en" "build-prod-window": "ng build --prod --localize && xcopy /E /Y dist\\en\\* dist\\ && rmdir /S /Q dist\\en"
}, },
"private": true, "private": true,
@ -58,10 +67,13 @@
"geodesy": "^1.1.3", "geodesy": "^1.1.3",
"intl": "^1.2.5", "intl": "^1.2.5",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-river": "^1.0.1",
"marked": "^1.2.9", "marked": "^1.2.9",
"mermaid": "^8.14.0",
"ngrx-store-localstorage": "^9.0.0", "ngrx-store-localstorage": "^9.0.0",
"ngx-captcha": "^8.0.1", "ngx-captcha": "^8.0.1",
"ngx-markdown": "^9.1.1", "ngx-markdown": "^9.1.1",
"polygon-clipping": "^0.15.7",
"primeng-lts": "^9.2.8", "primeng-lts": "^9.2.8",
"quill": "^1.3.7", "quill": "^1.3.7",
"rbush": "^3.0.1", "rbush": "^3.0.1",

View File

@ -0,0 +1,36 @@
const fs = require('fs');
const path = require('path');
const releasesDir = path.resolve(__dirname, '..', 'docs', 'releases');
const manifestPath = path.join(releasesDir, 'releases-manifest.json');
function toTitle(fileName) {
return path.basename(fileName, path.extname(fileName));
}
function getReleaseEntries() {
if (!fs.existsSync(releasesDir)) {
return [];
}
return fs.readdirSync(releasesDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === '.md')
.map((entry) => entry.name)
.sort((left, right) => right.localeCompare(left, undefined, { numeric: true, sensitivity: 'base' }))
.map((fileName) => ({
fileName,
title: toTitle(fileName)
}));
}
function main() {
fs.mkdirSync(releasesDir, { recursive: true });
const manifest = {
revisions: getReleaseEntries()
};
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
}
main();

View File

@ -106,10 +106,15 @@ const routes: Routes = [
runGuardsAndResolvers: 'always' runGuardsAndResolvers: 'always'
}, },
{ {
path: 'changelog', path: 'release-notes',
loadChildren: () => import('./changelog/changelog.module').then(m => m.ChangelogModule), loadChildren: () => import('./release-notes/release-notes.module').then(m => m.ReleaseNotesModule),
runGuardsAndResolvers: 'always' runGuardsAndResolvers: 'always'
}, },
{
path: 'changelog',
redirectTo: 'release-notes',
pathMatch: 'full'
},
], ],
}, },
{ {

View File

@ -59,7 +59,7 @@ export class AppMenuComponent implements OnInit {
{ id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] } { id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] }
] ]
}, },
{ id: 'changelog', label: $localize`:@@changelog:Changelog`, icon: 'history', routerLink: ['/changelog'] }, { id: 'release-notes', label: $localize`:@@releaseNotes:Release Notes`, icon: 'history', routerLink: ['/release-notes'] },
]; ];
this.model = mItems; this.model = mItems;
} }
@ -76,12 +76,7 @@ export class AppMenuComponent implements OnInit {
{ {
id: 'Help', id: 'Help',
label: $localize`:@@help:Help`, icon: 'help_outline', label: $localize`:@@help:Help`, icon: 'help_outline',
items: [{ items: this.buildHelpMenuItems()
label: $localize`:@@trainingVideos:Training Videos`,
icon: 'video_library',
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
target: '_blank'
}]
} }
]; ];
this.model = mItems; this.model = mItems;
@ -124,16 +119,28 @@ export class AppMenuComponent implements OnInit {
{ {
id: 'Help', id: 'Help',
label: $localize`:@@help:Help`, icon: 'help_outline', label: $localize`:@@help:Help`, icon: 'help_outline',
items: [{ items: this.buildHelpMenuItems()
label: $localize`:@@trainingVideos:Training Videos`,
icon: 'video_library',
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
target: '_blank'
}]
} }
) )
} }
private buildHelpMenuItems(): MenuItem[] {
return [
{
id: 'release-notes',
label: $localize`:@@releaseNotes:Release Notes`,
icon: 'history',
routerLink: ['/release-notes']
},
{
label: $localize`:@@trainingVideos:Training Videos`,
icon: 'video_library',
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
target: '_blank'
}
];
}
private addOnlyTrackingItems(mItems: MenuItem[]) { private addOnlyTrackingItems(mItems: MenuItem[]) {
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) { if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
mItems.push( mItems.push(

View File

@ -1,20 +0,0 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card card-w-title">
<h1 i18n="@@changelog">Changelog</h1>
<div *ngIf="loading" style="text-align:center; padding: 2rem;">
<p-progressSpinner></p-progressSpinner>
</div>
<ng-container *ngIf="!loading">
<p-panel
*ngFor="let section of sections; let i = index"
[header]="section.header"
[toggleable]="true"
[collapsed]="i !== 0"
styleClass="changelog-panel">
<div class="changelog-content" [innerHTML]="section.content"></div>
</p-panel>
</ng-container>
</div>
</div>
</div>

View File

@ -1,62 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import * as marked from 'marked';
const parseMarkdown: (src: string) => string = (marked as any).marked ?? (marked as any).default ?? (marked as any);
interface ChangelogSection {
header: string;
content: SafeHtml;
}
@Component({
selector: 'app-changelog',
templateUrl: './changelog.component.html'
})
export class ChangelogComponent implements OnInit {
sections: ChangelogSection[] = [];
loading = true;
constructor(
private readonly http: HttpClient,
private readonly sanitizer: DomSanitizer
) { }
ngOnInit(): void {
this.http.get('/assets/docs/CHANGELOG.md', { responseType: 'text' }).subscribe({
next: (md) => {
this.sections = this.parseSections(md);
this.loading = false;
},
error: () => { this.loading = false; }
});
}
private parseSections(md: string): ChangelogSection[] {
const sections: ChangelogSection[] = [];
let currentHeader = '';
let currentBody = '';
for (const line of md.split('\n')) {
if (line.startsWith('## ')) {
if (currentHeader) {
sections.push({
header: currentHeader,
content: this.sanitizer.bypassSecurityTrustHtml(parseMarkdown(currentBody.trim()))
});
}
currentHeader = line.replace(/^##\s*/, '');
currentBody = '';
} else if (currentHeader) {
currentBody += line + '\n';
}
}
if (currentHeader) {
sections.push({
header: currentHeader,
content: this.sanitizer.bypassSecurityTrustHtml(parseMarkdown(currentBody.trim()))
});
}
return sections;
}
}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { PanelModule } from 'primeng/panel';
import { AppSharedModule } from '../shared/app-shared.module';
import { ChangelogRoutingModule } from './changelog-routing.module';
import { ChangelogComponent } from './changelog.component';
@NgModule({
imports: [
AppSharedModule,
HttpClientModule,
ProgressSpinnerModule,
PanelModule,
ChangelogRoutingModule
],
declarations: [ChangelogComponent]
})
export class ChangelogModule { }

View File

@ -20,8 +20,6 @@
<span class="cache-ttl-help-icon">?</span> <span class="cache-ttl-help-icon">?</span>
<span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span> <span class="cache-ttl-help-text">Controls how long results stay cached after you return to this page. Value is in seconds.</span>
</span> </span>
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label>
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
</div> </div>
</div> </div>
</ng-template> </ng-template>

View File

@ -35,7 +35,6 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
partners: SelectItem[]; partners: SelectItem[];
cols: any[]; cols: any[];
totalItems; totalItems;
isSelfSignup = false;
searchAccordionOpen = sessionStorage.getItem('customers-list-accordion') === 'true'; searchAccordionOpen = sessionStorage.getItem('customers-list-accordion') === 'true';
private lastFiltersQuery: Record<string, any> | undefined; private lastFiltersQuery: Record<string, any> | undefined;
@ -74,13 +73,14 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
{ key: 'email', label: globals.email, dataType: 'text' }, { key: 'email', label: globals.email, dataType: 'text' },
{ key: 'contact', label: globals.contact, dataType: 'text' }, { key: 'contact', label: globals.contact, dataType: 'text' },
{ key: 'createdAt', label: globals.from, dataType: 'date-preset' }, { key: 'createdAt', label: globals.from, dataType: 'date-preset' },
{ key: 'selfSignup', label: 'Self Signup', dataType: 'select', options: [
{ label: 'True', value: true },
{ label: 'False', value: false },
]},
]; ];
} }
ngOnInit() { ngOnInit() {
const saved = localStorage.getItem('isSelfSignup');
this.isSelfSignup = saved === 'true';
this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => { this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
this.setCustomersAndPartners(customers); this.setCustomersAndPartners(customers);
}); });
@ -105,8 +105,7 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
} }
private setCustomersAndPartners(customers: Customer[]) { private setCustomersAndPartners(customers: Customer[]) {
const filtered = this.isSelfSignup ? customers.filter(c => c.selfSignup) : customers; this.customers = customers.map(c => ({
this.customers = filtered.map(c => ({
...c, ...c,
partnerName: c.partner?.name || null partnerName: c.partner?.name || null
})); }));
@ -120,14 +119,6 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
]; ];
} }
onToggle(event: any): void {
this.isSelfSignup = event.checked;
localStorage.setItem('isSelfSignup', String(this.isSelfSignup));
this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
this.setCustomersAndPartners(customers);
});
}
onRowSelect(event) { onRowSelect(event) {
this.store.dispatch(new customerActions.Select(event.data)); this.store.dispatch(new customerActions.Select(event.data));
} }

View File

@ -41,7 +41,7 @@ export class AuthInterceptor implements HttpInterceptor {
}); });
let url = req.url; let url = req.url;
if (!StringUtils.contains(req.url, '/track')) if (!StringUtils.contains(req.url, '/track') && !req.url.startsWith('/assets'))
url = `/api${!req.url.startsWith('/') ? '' + req.url : req.url}`; url = `/api${!req.url.startsWith('/') ? '' + req.url : req.url}`;
const authReq = req.clone({ url: url, headers: headers }); const authReq = req.clone({ url: url, headers: headers });

View File

@ -30,7 +30,7 @@ export const getEntityState = createFeatureSelector<EntityState>(FEATURE_KEY);
export const getCropsState = createSelector( export const getCropsState = createSelector(
getEntityState, getEntityState,
state => state.crops state => state ? state.crops : fromCrops.initialState
) )
export const { export const {
selectIds: getCropIds, selectIds: getCropIds,
@ -44,7 +44,7 @@ export const getCropsLoading = createSelector(getCropsState, fromCrops.getIsLoad
export const getPilotsState = createSelector( export const getPilotsState = createSelector(
getEntityState, getEntityState,
state => state.pilots state => state ? state.pilots : fromPilots.initialState
) )
export const { export const {
selectIds: getPilotIds, selectIds: getPilotIds,
@ -56,7 +56,7 @@ export const {
export const getProductsState = createSelector( export const getProductsState = createSelector(
getEntityState, getEntityState,
state => state.products state => state ? state.products : fromProducts.initialState
) )
export const { export const {
selectIds: getProductIds, selectIds: getProductIds,
@ -68,7 +68,7 @@ export const {
export const getVehilesState = createSelector( export const getVehilesState = createSelector(
getEntityState, getEntityState,
state => state.vehicles state => state ? state.vehicles : fromVehicles.initialState
) )
export const { export const {
selectIds: getVehicleIds, selectIds: getVehicleIds,

View File

@ -172,7 +172,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
{ key: 'name', label: globals.name, dataType: 'text' as const }, { key: 'name', label: globals.name, dataType: 'text' as const },
{ key: 'startDate', label: $localize`:@@startDate:Start Date`, dataType: 'date' as const }, { key: 'startDate', label: $localize`:@@startDate:Start Date`, dataType: 'date' as const },
{ key: 'endDate', label: $localize`:@@endDate:End Date`, dataType: 'date' as const }, { key: 'endDate', label: $localize`:@@endDate:End Date`, dataType: 'date' as const },
{ key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date-preset' as const }, { key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date-preset' as const, removable: false },
{ key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi' as const, options: GC.selJobStatuses }, { key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi' as const, options: GC.selJobStatuses },
]; ];
} }

View File

@ -0,0 +1,120 @@
.buf-editor-panel {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
background: #fff;
max-width: 50em;
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
border-radius: 4px;
font-size: 13px;
pointer-events: auto;
}
.buf-editor-toolbar {
display: flex;
align-items: center;
padding: 6px 8px;
border-bottom: 1px solid #e0e0e0;
background: #f5f5f5;
border-radius: 4px 4px 0 0;
}
.buf-editor-title {
font-weight: 500;
font-size: 13px;
}
.buf-editor-drag-handle {
display: flex;
align-items: center;
cursor: grab;
margin-right: 6px;
color: #757575;
user-select: none;
}
.buf-editor-drag-handle:active {
cursor: grabbing;
}
.buf-editor-footer {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid #e0e0e0;
align-items: center;
}
.buf-editor-body {
padding: 8px 10px;
}
.buf-editor-name-row {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 0 !important;
}
.buf-editor-name-label {
font-size: 12px;
white-space: nowrap;
}
.buf-editor-name-input.ui-inputtext {
flex: 1;
font-size: 12px;
padding: 2px 4px !important;
}
.buf-editor-controls-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
}
.buf-editor-width-group {
display: flex;
align-items: center;
white-space: nowrap;
gap: 6px;
}
.buf-editor-slider {
width: 100px;
cursor: pointer;
accent-color: #388e3c;
}
.buf-editor-width-input.ui-inputtext {
width: 54px !important;
text-align: right;
padding: 2px 4px !important;
font-size: 12px;
margin: 0 2px 0 4px;
}
.buf-editor-unit-label {
font-size: 12px;
color: #555;
min-width: 16px;
}
.buf-editor-icon-btn.ui-button {
width: 28px !important;
height: 28px !important;
padding: 0 !important;
}
.buf-editor-create-another-label {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 12px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}

View File

@ -0,0 +1,65 @@
<div *ngIf="active" class="buf-editor-panel leaflet-bar"
[style.top.px]="panelTop" [style.left.px]="panelLeft"
(mousedown)="$event.stopPropagation()" (dblclick)="$event.stopPropagation()"
(wheel)="$event.stopPropagation()">
<!-- Toolbar / drag handle -->
<div class="buf-editor-toolbar">
<span class="buf-editor-drag-handle" (mousedown)="dragStart.emit($event)" title="Drag to move">
<i class="ui-icon-drag-handle"></i>
</span>
<span class="buf-editor-title">{{ title }}</span>
</div>
<!-- Instruction text (before geometry is ready) -->
<div *ngIf="!confirmReady && instructionText" class="buf-editor-body">
<span>{{ instructionText }}</span>
</div>
<!-- Controls: name + width (always shown once panel is active) -->
<div class="buf-editor-body buf-editor-name-row">
<label class="buf-editor-name-label" i18n="@@name">Name</label>
<input type="text" pInputText maxlength="20" class="buf-editor-name-input"
[value]="name" (input)="nameChange.emit($any($event.target).value)">
</div>
<div class="buf-editor-body buf-editor-controls-row">
<div class="buf-editor-width-group">
<label i18n="@@width">Width</label>
<input type="range" min="1" max="100" step="1" [value]="widthSlider"
class="buf-editor-slider"
(input)="widthSliderChange.emit(+$any($event.target).value)">
<input type="number" pInputText min="1" [max]="maxWidthInUnit" step="1" [value]="widthInUnit"
class="buf-editor-width-input"
(change)="widthInputChange.emit(+$any($event.target).value)">
<span class="buf-editor-unit-label">{{ widthUnit }}</span>
</div>
<p-selectButton *ngIf="isEdge" [options]="edgeSideOptions" [ngModel]="edgeSide"
[ngModelOptions]="{standalone: true}"
(onChange)="edgeSideChange.emit($event.value)"></p-selectButton>
</div>
<!-- Optional projected content (e.g. feature-type selector) -->
<ng-content></ng-content>
<!-- Footer buttons -->
<div class="buf-editor-footer">
<button *ngIf="isEdge && canFlip" pButton type="button" icon="ui-icon-swap-horiz"
class="ui-button-secondary buf-editor-icon-btn"
i18n-pTooltip="@@flipDirection" pTooltip="Flip direction" tooltipPosition="top"
(click)="flipClick.emit()"></button>
<button *ngIf="confirmReady" pButton type="button" icon="ui-icon-check"
class="green-btn buf-editor-icon-btn"
i18n-pTooltip="@@confirm" pTooltip="Confirm" tooltipPosition="top"
(click)="confirmClick.emit()"></button>
<button pButton type="button" icon="ui-icon-cancel"
class="orange-btn buf-editor-icon-btn"
i18n-pTooltip="@@cancel" pTooltip="Cancel" tooltipPosition="top"
(click)="cancelClick.emit()"></button>
<label *ngIf="confirmReady && !isEditing" class="buf-editor-create-another-label">
<input type="checkbox" [checked]="createAnother"
(change)="createAnotherChange.emit($any($event.target).checked)">
<span i18n="@@createAnother">Create another</span>
</label>
</div>
</div>

View File

@ -0,0 +1,45 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { SelectItem } from 'primeng/api';
@Component({
selector: 'app-buf-editor-panel',
templateUrl: './buf-editor-panel.component.html',
styleUrls: ['./buf-editor-panel.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BufEditorPanelComponent {
/** Whether the panel is visible. */
@Input() active = false;
/** Title shown in the toolbar. */
@Input() title = 'Buffer Zone';
/** True = edge-buffer mode (shows edge-side selector + flip button). */
@Input() isEdge = false;
/** True once the geometry is ready and Confirm should be shown. */
@Input() confirmReady = false;
/** Hides "Create another" when editing an existing buffer. */
@Input() isEditing = false;
/** Instruction text shown before confirmReady. */
@Input() instructionText = '';
@Input() name = '';
@Input() widthSlider = 0;
@Input() widthInUnit = 30;
@Input() maxWidthInUnit = 100;
@Input() widthUnit: 'm' | 'ft' = 'm';
@Input() edgeSideOptions: SelectItem[] = [];
@Input() edgeSide = 'on';
@Input() canFlip = false;
@Input() createAnother = false;
@Input() panelTop = 10;
@Input() panelLeft = 10;
@Output() nameChange = new EventEmitter<string>();
@Output() widthSliderChange = new EventEmitter<number>();
@Output() widthInputChange = new EventEmitter<number>();
@Output() edgeSideChange = new EventEmitter<string>();
@Output() flipClick = new EventEmitter<void>();
@Output() confirmClick = new EventEmitter<void>();
@Output() cancelClick = new EventEmitter<void>();
@Output() createAnotherChange = new EventEmitter<boolean>();
@Output() dragStart = new EventEmitter<MouseEvent>();
}

View File

@ -3,6 +3,25 @@
margin-top : 1em; margin-top : 1em;
} }
/* Projected content inside app-buf-editor-panel — must live here due to Angular view encapsulation */
.buf-editor-feature-row {
padding: 0 10px 10px 10px;
gap: 6px;
display: flex;
align-items: center;
font-size: 12px;
}
.buf-editor-feature-row label {
white-space: nowrap;
}
.buf-editor-feature-row select {
flex: 1;
font-size: 12px;
padding: 2px 4px;
}
.data-detail-box { .data-detail-box {
border: 1px solid lightgray; border: 1px solid lightgray;
} }
@ -42,3 +61,193 @@
.loc-time { .loc-time {
padding-top: .45em; padding-top: .45em;
} }
/* Advanced Buffer Tools chooser panel */
.adv-buf-chooser-panel {
min-width: 200px;
}
.adv-buf-chooser-body {
padding: 10px 12px 6px;
display: flex;
flex-direction: column;
gap: 10px;
}
.adv-buf-chooser-label {
font-size: 12px;
color: #555;
}
.adv-buf-chooser-btns {
display: flex;
gap: 8px;
align-items: center;
}
.adv-buf-type-btn.ui-button {
width: 40px !important;
height: 40px !important;
padding: 0 !important;
font-size: 20px !important;
}
.adv-buf-type-btn.ui-button .ui-button-icon-left {
font-size: 20px;
margin-top: -10px;
margin-left: -10px;
}
.adv-buf-type-btn--disabled.ui-button {
opacity: 0.4;
cursor: not-allowed;
}
/* Edge Buffer overlay panel positioned inside the Leaflet map at top-left, matching Leaflet control pane */
.edge-buf-panel {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
background: #fff;
max-width: 50em;
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
border-radius: 4px;
font-size: 13px;
pointer-events: auto;
}
.edge-buf-panel-toolbar {
display: flex;
align-items: center;
padding: 6px 8px;
border-bottom: 1px solid #e0e0e0;
background: #f5f5f5;
border-radius: 4px 4px 0 0;
}
.edge-buf-panel-title {
font-weight: 500;
font-size: 13px;
}
.edge-buf-drag-handle {
display: flex;
align-items: center;
cursor: grab;
margin-right: 6px;
color: #757575;
user-select: none;
}
.edge-buf-drag-handle:active {
cursor: grabbing;
}
.edge-buf-panel-footer {
display: flex;
gap: 6px;
padding: 8px 10px;
border-top: 1px solid #e0e0e0;
justify-content: flex-start;
}
.edge-buf-controls-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
}
.edge-buf-width-group {
display: flex;
align-items: center;
white-space: nowrap;
gap: 6px;
}
.edge-buf-slider {
width: 100px;
cursor: pointer;
accent-color: #388e3c;
}
.edge-buf-width-input.ui-inputtext {
width: 54px !important;
text-align: right;
padding: 2px 4px !important;
font-size: 12px;
margin: 0 2px 0 4px;
}
.edge-buf-unit-select {
font-size: 12px;
border: 1px solid #bdbdbd;
border-radius: 3px;
padding: 1px 2px;
cursor: pointer;
background: #fff;
}
.edge-buf-icon-btn.ui-button {
width: 28px !important;
height: 28px !important;
padding: 0 !important;
}
.edge-buf-panel-body {
padding: 8px 10px;
}
.edge-buf-name-row {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 0 !important;
}
.edge-buf-name-label {
font-size: 12px;
white-space: nowrap;
}
.edge-buf-name-input.ui-inputtext {
flex: 1;
font-size: 12px;
padding: 2px 4px !important;
}
.edge-buf-create-another-label {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
font-size: 12px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
/* Edge Buffer snap-to-edge drawing mode */
.edge-buf-snap-marker {
width: 12px;
height: 12px;
background: #fff;
border: 2px solid #e65100;
border-radius: 50%;
}
.edge-buf-cursor-tip {
position: fixed;
pointer-events: none;
z-index: 9999;
background: rgba(0, 0, 0, 0.72);
color: #fff;
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
white-space: nowrap;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
left: -9999px;
top: -9999px;
}

View File

@ -71,7 +71,7 @@
<ng-template pTemplate="paginatorleft" let-state> <ng-template pTemplate="paginatorleft" let-state>
{{ state.totalRecords | i18nPlural: totalItems }} {{ state.totalRecords | i18nPlural: totalItems }}
</ng-template> </ng-template>
<ng-template pTemplate="emptymessage"> <ng-template pTemplate="empty sage">
<tr> <tr>
<td [attr.colspan]="4"> <td [attr.colspan]="4">
<div class="ui-messages-error" *ngIf="!hasItems()"> <div class="ui-messages-error" *ngIf="!hasItems()">
@ -157,6 +157,171 @@
</p-toolbar> </p-toolbar>
</div> </div>
<div #map id="map" [style.height]="mapHeight" leaflet (leafletMapReady)="onMapReady($event)" [leafletOptions]="mapOps" [leafletLayers]="layers" [leafletLayersControl]="layersControl"> <div #map id="map" [style.height]="mapHeight" leaflet (leafletMapReady)="onMapReady($event)" [leafletOptions]="mapOps" [leafletLayers]="layers" [leafletLayersControl]="layersControl">
<!-- Advanced Buffer Tools: Buffer Creation Mode chooser panel -->
<div *ngIf="advBufPanelActive && !edgeBufActive && !segBufPanelActive && !featureBufActive && !featureBufPanelActive" class="edge-buf-panel leaflet-bar adv-buf-chooser-panel"
[style.top.px]="edgeBufPanelTop" [style.left.px]="edgeBufPanelLeft"
(mousedown)="$event.stopPropagation()" (dblclick)="$event.stopPropagation()"
(wheel)="$event.stopPropagation()">
<div class="edge-buf-panel-toolbar">
<span class="edge-buf-drag-handle" (mousedown)="onEdgeBufPanelDragStart($event)" title="Drag to move">
<i class="ui-icon-drag-handle"></i>
</span>
<span class="edge-buf-panel-title" i18n="@@advBufTitle">Buffer Creation Mode</span>
</div>
<div class="adv-buf-chooser-body">
<span class="adv-buf-chooser-label" i18n="@@advBufSelectType">Please select a buffer type</span>
<div class="adv-buf-chooser-btns">
<button pButton type="button" icon="ui-icon-timeline"
class="ui-button-secondary adv-buf-type-btn"
pTooltip="Segment" tooltipPosition="bottom"
(click)="onAdvBufSegment()"></button>
<button pButton type="button" icon="ui-icon-border-style"
class="ui-button-secondary adv-buf-type-btn"
pTooltip="Edge" tooltipPosition="bottom"
(click)="onAdvBufEdge()"></button>
<button pButton type="button" icon="ui-icon-terrain"
class="ui-button-secondary adv-buf-type-btn"
pTooltip="Feature" tooltipPosition="bottom"
(click)="onAdvBufFeature()"></button>
</div>
</div>
<div class="edge-buf-panel-footer">
<button pButton type="button" icon="ui-icon-cancel"
class="orange-btn edge-buf-icon-btn"
i18n-pTooltip="@@cancel" pTooltip="Cancel" tooltipPosition="top"
(click)="closeAdvBuf()"></button>
</div>
</div>
<!-- Segment Buffer Zone editor panel -->
<app-buf-editor-panel
[active]="segBufPanelActive"
title="Segment Buffer Zone"
[isEdge]="false"
[confirmReady]="segBufConfirmReady"
[isEditing]="false"
[name]="segBufName"
[widthSlider]="_bufWidthSlider"
[widthInUnit]="bufWidthInUnit"
[maxWidthInUnit]="maxBufInUnit"
[widthUnit]="bufWidthUnit"
[createAnother]="segBufCreateAnother"
[panelTop]="edgeBufPanelTop"
[panelLeft]="edgeBufPanelLeft"
(nameChange)="segBufName = $event"
(widthSliderChange)="onBufWidthSliderChange($event)"
(widthInputChange)="onBufWidthInputChange($event)"
(confirmClick)="confirmSegBuf()"
(cancelClick)="cancelSegBuf()"
(createAnotherChange)="segBufCreateAnother = $event"
(dragStart)="onEdgeBufPanelDragStart($event)">
</app-buf-editor-panel>
<!-- Feature Buffer Zone editor panel (water bodies + schools via OpenStreetMap) -->
<app-buf-editor-panel
[active]="featureBufPanelActive || featureBufLoading"
title="Feature Buffer Zone"
[isEdge]="false"
[confirmReady]="featureBufConfirmReady && !featureBufLoading"
[isEditing]="false"
[instructionText]="featureBufLoading ? 'Loading features from OpenStreetMap…' : (!featureBufConfirmReady ? 'No features found in the selected area.' : '')"
[name]="featureBufName"
[widthSlider]="_bufWidthSlider"
[widthInUnit]="bufWidthInUnit"
[maxWidthInUnit]="maxBufInUnit"
[widthUnit]="bufWidthUnit"
[createAnother]="featureBufCreateAnother"
[panelTop]="edgeBufPanelTop"
[panelLeft]="edgeBufPanelLeft"
(nameChange)="featureBufName = $event"
(widthSliderChange)="onBufWidthSliderChange($event)"
(widthInputChange)="onBufWidthInputChange($event)"
(confirmClick)="confirmFeatureBuf()"
(cancelClick)="cancelFeatureBuf()"
(createAnotherChange)="featureBufCreateAnother = $event"
(dragStart)="onEdgeBufPanelDragStart($event)">
<div class="buf-editor-feature-row">
<label class="buf-editor-name-label">Feature</label>
<select [value]="featureBufFeatureType"
(change)="onFeatureBufFeatureTypeChange($any($event.target).value)">
<option value="water">Water</option>
<option value="schools">Schools</option>
<option value="both">All</option>
</select>
</div>
</app-buf-editor-panel>
<!-- Edge Buffer Zone editor dialog (opened by clicking an existing buffer on the map) -->
<p-dialog header="Edge Buffer Zone" [(visible)]="edgeBufDlgVisible"
modal="true" [resizable]="false" [style]="{'width':'380px'}"
[contentStyle]="{'overflow':'visible'}"
styleClass="edge-buf-dialog"
(onHide)="cancelEdgeBuf()">
<div class="ui-g ui-g-fluid ui-g-nopad" style="margin-bottom: 16px">
<div class="ui-g-12 ui-g-nopad">
<div class="ui-g-4"><label i18n="@@name">Name</label></div>
<div class="ui-g-8">
<input type="text" pInputText maxlength="20"
[value]="edgeBufName" (input)="edgeBufName = $any($event.target).value">
</div>
</div>
<div class="ui-g-12 ui-g-nopad">
<div class="ui-g-4"><label i18n="@@width">Width</label></div>
<div class="ui-g-8">
<input type="number" pInputText min="1" [max]="maxBufInUnit" step="1"
[value]="bufWidthInUnit" style="width:120px"
(change)="onBufWidthInputChange(+$any($event.target).value)">
<span> {{ bufWidthUnit }}</span>
</div>
</div>
<div class="ui-g-12 ui-g-nopad">
<div class="ui-g-4"><label i18n="@@edgeSide">Edge Side</label></div>
<div class="ui-g-8">
<p-selectButton [options]="edgeSideOptions" [ngModel]="bufEdgeSide"
[ngModelOptions]="{standalone: true}"
styleClass="edge-buf-side-btn"
(onChange)="onEdgeSideChange($event.value)"></p-selectButton>
</div>
</div>
</div>
<p-footer>
<div class="ui-helper-clearfix">
<button *ngIf="canFlipEdgeBuf" pButton type="button" icon="ui-icon-swap-horiz"
class="ui-button-secondary" style="margin-right:4px"
i18n-label="@@flipDirection" label="Flip"
(click)="flipEdgeBufDirection()"></button>
<button pButton type="button" icon="ui-icon-save"
i18n-label="@@OK" label="OK"
(click)="confirmEdgeBuf()"></button>
</div>
</p-footer>
</p-dialog>
<!-- Edge Buffer Zone editor panel (floating, used during toolbar-driven creation) -->
<app-buf-editor-panel
[active]="edgeBufActive"
title="Edge Buffer Zone"
[isEdge]="true"
[confirmReady]="edgeBufConfirmReady"
[isEditing]="edgeBufIsEditing"
instructionText=""
[name]="edgeBufName"
[widthSlider]="_bufWidthSlider"
[widthInUnit]="bufWidthInUnit"
[maxWidthInUnit]="maxBufInUnit"
[widthUnit]="bufWidthUnit"
[edgeSideOptions]="edgeSideOptions"
[edgeSide]="bufEdgeSide"
[canFlip]="canFlipEdgeBuf"
[createAnother]="edgeBufCreateAnother"
[panelTop]="edgeBufPanelTop"
[panelLeft]="edgeBufPanelLeft"
(nameChange)="edgeBufName = $event"
(widthSliderChange)="onBufWidthSliderChange($event)"
(widthInputChange)="onBufWidthInputChange($event)"
(edgeSideChange)="onEdgeSideChange($event)"
(flipClick)="flipEdgeBufDirection()"
(confirmClick)="confirmEdgeBuf()"
(cancelClick)="cancelEdgeBuf()"
(createAnotherChange)="edgeBufCreateAnother = $event"
(dragStart)="onEdgeBufPanelDragStart($event)">
</app-buf-editor-panel>
</div> </div>
</div> </div>
</div> </div>
@ -212,8 +377,16 @@
<label for="width" i18n="@@width">Width</label> <label for="width" i18n="@@width">Width</label>
</div> </div>
<div class="ui-g-8"> <div class="ui-g-8">
<input id="width" name="width" type="number" [min]="minBuf" [max]="maxBuf" step="0.5" [(ngModel)]="curItem.width" pInputText pKeyFilter="pnum" style="width:120px"> <input id="width" name="width" type="number" [min]="minBuf" [max]="maxBuf" step="1" [(ngModel)]="curItem.width" pInputText pKeyFilter="pnum" style="width:120px">
<span>{{ job.measureUnit | lengthUnit }}</span> <span>{{ job.measureUnit | lengthUnit }}</span>
</div>
</div>
<div *ngIf="curItem.type === ITEM.BUFFER && curItem.edgeSide != null" class="ui-g-12 ui-g-nopad">
<div class="ui-g-4">
<label i18n="@@edgeSide">Edge Side</label>
</div>
<div class="ui-g-8">
<p-selectButton [options]="edgeSideOptions" [(ngModel)]="curItem.edgeSide" name="edgeSide"></p-selectButton>
</div> </div>
</div> </div>
</div> </div>
@ -583,6 +756,8 @@
</div> </div>
</p-dialog> </p-dialog>
<p-dialog #gridGen position="topleft" showEffect="fade" [(visible)]="gridGenOn" header="" [resizable]="false" [closable]="false" [closeOnEscape]="false" [contentStyle]="{'overflow':'visible'}" [style]="{ 'width': '300px'}" [modal]="false"> <p-dialog #gridGen position="topleft" showEffect="fade" [(visible)]="gridGenOn" header="" [resizable]="false" [closable]="false" [closeOnEscape]="false" [contentStyle]="{'overflow':'visible'}" [style]="{ 'width': '300px'}" [modal]="false">
<div class="ui-g-12 ui-g-nopad"> <div class="ui-g-12 ui-g-nopad">
<p-toolbar> <p-toolbar>
@ -627,244 +802,11 @@
</div> </div>
</p-dialog> </p-dialog>
<p-dialog #playbackSpr position="topleft" showEffect="fade" [(visible)]="playbackOn" header="" [resizable]="false" [closable]="false" [closeOnEscape]="false" [contentStyle]="{'overflow':'visible'}" [style]="{ 'width': '360px' }" [modal]="false" (onShow)="onPlayDlgShow()">
<div class="ui-g-12 ui-g-nopad">
<p-toolbar>
<div class="ui-toolbar-group-left toolbar-nopad">
<div i18n-pTooltip="@@playAuto" pTooltip="Play Auto" class='button-ttip' tooltipPosition="bottom" [tooltipDisabled]="isMobile">
<p-toggleButton [(ngModel)]="playAutoOn" [disabled]="!dataFiles.length" onIcon="ui-icon-play-arrow" offIcon="ui-icon-play-arrow" iconPos="left" onLabel="" offLabel="" (onChange)="togglePlay($event, PlayModes.Auto)">
</p-toggleButton>
</div>
<div i18n-pTooltip="@@playManual" pTooltip="Play Manual" class='button-ttip' tooltipPosition="bottom" [tooltipDisabled]="isMobile">
<p-toggleButton [(ngModel)]="playManualOn" [disabled]="!dataFiles.length" onIcon="ui-icon-play-circle-outline" offIcon="ui-icon-play-circle-outline" styleClass="round-button" onLabel="" offLabel="" (onChange)="togglePlay($event, PlayModes.Manual)">
</p-toggleButton>
</div>
<div i18n-pTooltip="@@centerPlayPos" pTooltip="Center on Play position" class='button-ttip' tooltipPosition="bottom" [tooltipDisabled]="isMobile"> <app-playback-panel #playbackPanel
<p-toggleButton [(ngModel)]="centerPlayPos" onIcon="ui-icon-center-focus-strong" offIcon="ui-icon-center-focus-strong" styleClass="round-button" onLabel="" offLabel=""> [job]="currentJob"
</p-toggleButton> [map]="map"
</div> [settings]="settings"
</div> [totalArea]="totalArea"
</p-toolbar> [totalAmount]="totalAmount">
</div> </app-playback-panel>
<div class="ui-g-12">
<div *ngIf="!playManualOn else manCtl;" class="speed-slider">
<p-slider styleClass="my-slider" [(ngModel)]="playSpd" (onSlideEnd)="playSpdChange($event)"></p-slider>
</div>
<ng-template #manCtl>
<div class="manual-controls">
<button pButton icon="ui-icon-keyboard-arrow-left" type="button" (click)="playManualBwd()"></button>
<button pButton icon="ui-icon-keyboard-arrow-right" type="button" (click)="playManualFwd()"></button>
</div>
</ng-template>
</div>
<div class="ui-g-12 ui-g-nopad data-detail-box" *ngIf="curPlayRec">
<p-tabView #tbvOutputs styleClass="slim-tabview" (onChange)="tbvChange($event)">
<p-tabPanel header="Files">
<p-orderList #dfilesList styleClass="my-orderlist" [value]="dataFiles" [(selection)]="selDataFiles" [style]="{'minHeight':'194px'}" controlsPosition="right" i18n-header="@@dataFiles" header="Data Files" [metaKeySelection]="true" (onReorder)="onReOrder($event)" (onSelectionChange)="dataFilesSelChange($event)">
<ng-template let-file pTemplate="item">
<div class="ui-helper-clearfix" [ngClass]="{'play-file':file.playing}">
{{ fileItem(file) }}
</div>
</ng-template>
</p-orderList>
</p-tabPanel>
<p-tabPanel i18n-header="@@gpsData" header="GPS Data">
<div class="ui-g ui-g-nopad output">
<div class="ui-g-4 data-field field-name">Counter</div>
<div class="ui-g-8 data-field">{{(playIdx + 1)}}</div>
<div class="ui-g-4 data-field field-name loc-time">TimeLocal</div>
<div class="ui-g-8 data-field loc-time-v">
<div>
{{curPlayRec.timeLocal || "00:00:00.0"}}
</div>
<div *ngIf="isPlayingAgNavFile">
<p-dropdown id="tzone" name="tzone" [style]="{'width':'50px'}" [(ngModel)]="localTz" [options]="timeZones" (onChange)="onTzChange($event)"></p-dropdown>
</div>
</div>
<div class="ui-g-4 data-field field-name">TimeGPS</div>
<div class="ui-g-8 data-field">{{curPlayRec.timeGPS?.toFixed(2)}}</div>
<div class="ui-g-4 data-field field-name">Latitude</div>
<div class="ui-g-8 data-field">{{curPlayRec.lat | coordinate:'lat':'DM' }}</div>
<div class="ui-g-4 data-field field-name">Longitude</div>
<div class="ui-g-8 data-field">{{curPlayRec.lon | coordinate:'lon':'DM' }}</div>
<div class="ui-g-4 data-field field-name">UTM X</div>
<div class="ui-g-8 data-field">{{NumUtils.fixedTo(curPlayRec.utmX, 1, '0.0')}}</div>
<div class="ui-g-4 data-field field-name">UTM Y</div>
<div class="ui-g-8 data-field">{{NumUtils.fixedTo(curPlayRec.utmY, 1, '0.0')}}</div>
<div class="ui-g-4 data-field field-name">Altitude</div>
<div class="ui-g-8 data-field">{{curPlayRec.alt | length:isUS:0}}</div>
<div class="ui-g-4 data-field field-name">Speed</div>
<div class="ui-g-8 data-field">{{curPlayRec.speed | speed:isUS:true}}</div>
<div class="ui-g-4 data-field field-name">AvgSXt/ Xt</div>
<div class="ui-g-8 data-field">{{playXt.avg | length:isUS:0 }} / {{curPlayRec.xt | xtract:isUS:0}}</div>
<div class="ui-g-4 data-field field-name">TrckAngle</div>
<div class="ui-g-8 data-field">{{curPlayRec.trckAngle}}</div>
<ng-container *ngIf="isPlayingAgNavFile">
<div class="ui-g-4 data-field field-name">LckedLine</div>
<div class="ui-g-8 data-field">{{curPlayRec.lockedLine | lockline:curPlayLoc?.xTrack }}</div>
</ng-container>
<div class="ui-g-4 data-field field-name">HDOP</div>
<div class="ui-g-8 data-field">{{curPlayRec.hdop}}</div>
<div class="ui-g-4 data-field field-name">Sat/Cor/ID</div>
<div class="ui-g-8 data-field">{{curPlayRec.sats || 0}} / {{curPlayRec.corId || 0}}<span *ngIf="curPlayRec.waasId">/ {{curPlayRec.waasId}}</span></div>
<ng-container *ngIf="isDebug">
<div class="ui-g-4 data-field field-name">SprayStat </div>
<div class="ui-g-8 data-field">{{curPlayLoc?.sprayStat}} (DEBUG)</div>
</ng-container>
</div>
</p-tabPanel>
<p-tabPanel i18n-header="@@applicInfo" header="Applic Info">
<div class="ui-g ui-g-nopad output">
<ng-container *ngIf="(playMatType === MatType.LIQUID) else DRYRATE">
<div class="ui-g-4 data-field field-name">Applic.RateAp</div>
<div class="ui-g-8 data-field">{{ curPlayRec.appRateAp | appRate:playMatType:isUS:null:false }}</div>
<div class="ui-g-4 data-field field-name">Applic.RateRq</div>
<ng-container *ngIf="isPlayingAgNavFile; else PARTNERATE">
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}</div>
</ng-container>
<ng-template #PARTNERATE>
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}</div>
</ng-template>
<div class="ui-g-4 data-field field-name">FlowRateAp
</div>
<div class="ui-g-8 data-field">{{curPlayRec.flowRateAp || 0 | flowRate:isUS }}</div>
<div class="ui-g-4 data-field field-name">FlowRateRq</div>
<div class="ui-g-8 data-field">{{curPlayRec.flowRateRq || 0 | flowRate:isUS }}</div>
</ng-container>
<ng-template #DRYRATE>
<div class="ui-g-4 data-field field-name">AvgRMP</div>
<div class="ui-g-8 data-field">{{(curPlayRec.avgRMP || 0) | number:'1.1-1':'en' }}</div>
<div class="ui-g-4 data-field field-name">AppRateAp</div>
<div class="ui-g-8 data-field">{{(curPlayRec.flowRateAp || 0) | appRate:playMatType:isUS }}</div>
<div class="ui-g-4 data-field field-name">AppRateRq</div>
<div class="ui-g-8 data-field">{{(curPlayRec.flowRateRq || 0) | appRate:playMatType:isUS }}</div>
</ng-template>
<div class="ui-g-4 data-field field-name">Flow Control</div>
<div class="ui-g-8 data-field">{{curPlayRec.flowControl }}</div>
<ng-container *ngIf="isPlayingAgNavFile && (playMatType === MatType.LIQUID)">
<div class="ui-g-4 data-field field-name">Bm Pressure</div>
<div class="ui-g-8 data-field">{{curPlayRec.bmPressure | number:'1.1-1':'en'}} psi</div>
</ng-container>
<div class="ui-g-4 data-field field-name">Area</div>
<div class="ui-g-8 data-field">{{ curPlayRec.area | number:'1.1-1':'en'}} {{ currentJob.measureUnit | areaUnit:false }}</div>
<div class="ui-g-4 data-field field-name">AreaSprIn</div>
<div class="ui-g-8 data-field">{{ UnitUtils.toArea(areaSprIn.total, isUS) | number:'1.1-1':'en'}} {{ currentJob.measureUnit | areaUnit:false }}</div>
<div class="ui-g-4 data-field field-name">AreaSprTot</div>
<div class="ui-g-8 data-field">{{ UnitUtils.toArea(areaSprTot.total, isUS) | number:'1.1-1':'en'}} {{ currentJob.measureUnit | areaUnit:false }}</div>
<div class="ui-g-4 data-field field-name">Swath Width</div>
<div class="ui-g-8 data-field">{{curPlayRec.swathWidth | length:isUS }}</div>
<div class="ui-g-4 data-field field-name">AvgSprSpd</div>
<div class="ui-g-8 data-field">{{sprSpd.avg | speed:isUS:true }}</div>
<div class="ui-g-4 data-field field-name">{{playMatType === MatType.LIQUID ? 'RPM' : 'AppRPM' }} 1/2</div>
<div class="ui-g-8 data-field">{{curPlayRec.rpm[0] || 0}} / {{curPlayRec.rpm[1] || 0}}</div>
<div class="ui-g-4 data-field field-name">{{playMatType === MatType.LIQUID ? 'RPM 3/4' : 'TarRPM 1/2' }}</div>
<div class="ui-g-8 data-field">{{curPlayRec.rpm[2] || 0}} / {{curPlayRec.rpm[3] || 0}}</div>
<ng-container *ngIf="playMatType === MatType.LIQUID else DRYRPM">
<div class="ui-g-4 data-field field-name">RPM 5/6</div>
<div class="ui-g-8 data-field">{{curPlayRec.rpm[4] || 0}} / {{curPlayRec.rpm[5] || 0}}</div>
<div class="ui-g-4 data-field field-name">RPM 7/8</div>
<div class="ui-g-8 data-field">{{curPlayRec.rpm[6] || 0}} / {{curPlayRec.rpm[7] || 0}}</div>
<div class="ui-g-4 data-field field-name">RPM 9/10</div>
<div class="ui-g-8 data-field">{{curPlayRec.rpm[8] || 0}} / {{curPlayRec.rpm[9] || 0}}</div>
</ng-container>
<ng-template #DRYRPM>
<div class="ui-g-4 data-field field-name">GFC VIn</div>
<div class="ui-g-8 data-field">{{curPlayRec.rpm[4] | number:'1.1-1':'en'}}</div>
<div class="ui-g-4 data-field field-name">Revs/Lb</div>
<div class="ui-g-8 data-field">{{UnitUtils.revkgTolb(curPlayRec.rpm[6]) | number:'1.2-2':'en'}} / {{UnitUtils.revkgTolb(curPlayRec.rpm[7]) | number:'1.2-2':'en'}}</div>
<div class="ui-g-4 data-field field-name">Amp 1/2</div>
<div class="ui-g-8 data-field">{{curPlayRec.rpm[8] | number:'1.1-1':'en'}} / {{curPlayRec.rpm[9] | number:'1.1-1':'en'}}</div>
</ng-template>
<div class="ui-g-4 data-field field-name">AutoSpr On/Off</div>
<div class="ui-g-8 data-field">{{curPlayRec.sprOnLag | number:'1.2-2':'en' }} / {{curPlayRec.sprOffLag | number:'1.2-2':'en'}}</div>
<ng-container *ngIf="isPlayingAgNavFile">
<div class="ui-g-4 data-field field-name" *ngIf="playMatType === MatType.LIQUID">Pulses/Liter</div>
<div class="ui-g-8 data-field" *ngIf="playMatType === MatType.LIQUID">{{curPlayRec.pulsesPLiter | number:'1.0-0':'en'}}</div>
</ng-container>
</div>
</p-tabPanel>
<p-tabPanel i18n-header="@@met" header="MET">
<div class="ui-g ui-g-nopad output">
<div class="ui-g-4 data-field field-name">Counter</div>
<div class="ui-g-8 data-field">{{(playIdx + 1)}}</div>
<div class="ui-g-4 data-field field-name">TimeLocal</div>
<div class="ui-g-8 data-field">{{curPlayRec.timeLocal || "00:00:00.0"}}</div>
<div class="ui-g-4 data-field field-name">TimeGPS</div>
<div class="ui-g-8 data-field">{{curPlayRec.timeGPS}}</div>
<div class="ui-g-4 data-field field-name" field-name>Latitude</div>
<div class="ui-g-8 data-field">{{curPlayRec.lat | coordinate:'lat':'DM' }}</div>
<div class="ui-g-4 data-field field-name">Longitude</div>
<div class="ui-g-8 data-field">{{curPlayRec.lon | coordinate:'lon':'DM' }}</div>
<div class="ui-g-4 data-field field-name">UTM X</div>
<div class="ui-g-8 data-field">{{NumUtils.fixedTo(curPlayRec.utmX, 1, '0.0')}}</div>
<div class="ui-g-4 data-field field-name">UTM Y</div>
<div class="ui-g-8 data-field">{{NumUtils.fixedTo(curPlayRec.utmY, 1, '0.0')}}</div>
<div class="ui-g-4 data-field field-name">Speed</div>
<div class="ui-g-8 data-field">{{UnitUtils.mpsToKnot(curPlayRec.speed) | number:'1.1-1':'en'}} knots</div>
<ng-container *ngIf="isPlayingAgNavFile">
<div class="ui-g-4 data-field field-name">LckedLine</div>
<div class="ui-g-8 data-field">{{curPlayRec.lockedLine | lockline}}</div>
</ng-container>
<div class="ui-g-4 data-field field-name">Wind Spd</div>
<div class="ui-g-8 data-field">{{curPlayRec.windSpd | number:'1.1-1':'en' }} knots</div>
<div class="ui-g-4 data-field field-name">Wind Dir</div>
<div class="ui-g-8 data-field">{{curPlayRec.windDir | number:'1.1-1':'en' }} °</div>
<div class="ui-g-4 data-field field-name">Temperature</div>
<div class="ui-g-8 data-field">{{curPlayRec.temp | temperature:isUS }}</div>
<div class="ui-g-4 data-field field-name">Humidity</div>
<div class="ui-g-8 data-field">{{curPlayRec.humid}} %</div>
<div class="ui-g-4 data-field field-name">Spr. Height</div>
<div class="ui-g-8 data-field">{{curPlayRec.sprHeight | length:isUS:0 }}</div>
<div class="ui-g-4 data-field field-name">DriftX</div>
<div class="ui-g-8 data-field">{{curPlayRec.driftX | length:isUS:0 }}</div>
<div class="ui-g-4 data-field field-name">DriftY</div>
<div class="ui-g-8 data-field">{{curPlayRec.driftY | length:isUS:0 }}</div>
<div class="ui-g-4 data-field field-name">DepositX</div>
<div class="ui-g-8 data-field">{{curPlayRec.depositX | length:isUS:0 }}</div>
<div class="ui-g-4 data-field field-name">DepositY</div>
<div class="ui-g-8 data-field">{{curPlayRec.depositY | length:isUS:0 }}</div>
<div class="ui-g-4 data-field field-name">GPS Alt</div>
<div class="ui-g-8 data-field">{{curPlayRec.alt | length:isUS:0 }}</div>
<div class="ui-g-4 data-field field-name">Rad/Laser Alt</div>
<div class="ui-g-8 data-field">{{curPlayRec.radAlt | length:isUS:0 }} / {{curPlayRec.laserAlt | length:isUS:0}}</div>
</div>
</p-tabPanel>
<p-tabPanel i18n-header="@@summary" header="Summary">
<div class="ui-g ui-g-nopad output">
<ng-container *ngIf="isPlayingAgNavFile">
<div class="ui-g-4 data-field field-name">AreaName</div>
<div class="ui-g-8 data-field">{{curPlayRec.areaName}}</div>
</ng-container>
<div class="ui-g-4 data-field field-name">Mapped Area</div>
<div class="ui-g-8 data-field">{{curPlayRec.mappedArea | number:'1.1-1':'en' }} {{ currentJob.measureUnit | areaUnit:false }}</div>
<div class="ui-g-4 data-field field-name">AreaSprTot</div>
<div class="ui-g-8 data-field">{{ UnitUtils.toArea(areaSprTot.total, isUS) | number:'1.1-1':'en'}} {{ currentJob.measureUnit | areaUnit:false }}</div>
<div class="ui-g-4 data-field field-name">AvgSprSpd</div>
<div class="ui-g-8 data-field">{{sprSpd.avg | speed:isUS:true}}</div>
<div class="ui-g-4 data-field field-name">Pilot Name</div>
<div class="ui-g-8 data-field">{{curPlayRec.pilotName}}</div>
<div class="ui-g-4 data-field field-name">Applic.Rate</div>
<!-- <div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:null:false }}</div> -->
<ng-container *ngIf="isPlayingAgNavFile; else PARTNERATE">
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}</div>
</ng-container>
<ng-template #PARTNERATE>
<div class="ui-g-8 data-field">{{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}</div>
</ng-template>
<div class="ui-g-4 data-field field-name">Mat Needed</div>
<div class="ui-g-8 data-field">{{( totalAmount?.value || 0) | number:'1.1-1':'en'}} {{ totalAmount?.appRateUnit | rateUnit:1:false }}</div>
<div class="ui-g-4 data-field field-name">Mat Sprayed</div>
<div class="ui-g-8 data-field">{{ matSprayed.total | appVolume:totalAmount?.appRateUnit }} </div>
<div class="ui-g-4 data-field field-name">OverSprayed</div>
<div class="ui-g-8 data-field">{{curPlayRec.overSprayed | number:'1.1-1':'en'}}%</div>
</div>
</p-tabPanel>
</p-tabView>
</div>
</p-dialog>

View File

@ -24,6 +24,7 @@ import { TabViewModule } from 'primeng/tabview';
import { SliderModule } from 'primeng/slider'; import { SliderModule } from 'primeng/slider';
import { OrderListModule } from 'primeng/orderlist'; import { OrderListModule } from 'primeng/orderlist';
import { AccordionModule } from 'primeng/accordion'; import { AccordionModule } from 'primeng/accordion';
import { SelectButtonModule } from 'primeng/selectbutton';
import { StoreModule } from '@ngrx/store'; import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
@ -38,6 +39,7 @@ import { JobListComponent } from './job-list/job-list.component';
import { JobEditComponent } from './job-edit/job-edit.component'; import { JobEditComponent } from './job-edit/job-edit.component';
import { JobAssignmentComponent } from './job-assignment/job-assignment.component'; import { JobAssignmentComponent } from './job-assignment/job-assignment.component';
import { JobMapEditComponent } from './job-map-edit/job-map-edit.component'; import { JobMapEditComponent } from './job-map-edit/job-map-edit.component';
import { BufEditorPanelComponent } from './job-map-edit/buf-editor-panel/buf-editor-panel.component';
import { JobsRoutingModule } from './job-routing.module'; import { JobsRoutingModule } from './job-routing.module';
import { InvoicesModule } from '@app/invoices/invoices.module'; import { InvoicesModule } from '@app/invoices/invoices.module';
@ -48,14 +50,14 @@ import { InvoicesModule } from '@app/invoices/invoices.module';
PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule, PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule,
CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule, CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule,
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule, AccordionModule, CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule, AccordionModule,
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule, PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule, SelectButtonModule,
JobsRoutingModule, JobsRoutingModule,
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer), StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer), StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
EffectsModule.forFeature([JobEffects, ClientEffects]), InvoicesModule, EffectsModule.forFeature([JobEffects, ClientEffects]), InvoicesModule,
], ],
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent], declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent, BufEditorPanelComponent],
providers: [DatePipe], providers: [DatePipe],
schemas: [ schemas: [
CUSTOM_ELEMENTS_SCHEMA CUSTOM_ELEMENTS_SCHEMA

View File

@ -29,6 +29,12 @@ export interface BufferZone {
type: ITEM; type: ITEM;
name?: string; name?: string;
width: number; width: number;
/** Computed display offset in metres (derived from edgeSide + width). */
offset?: number;
/** Which side of the traced edge the corridor occupies. */
edgeSide?: 'on' | 'inside' | 'outside';
/** Winding sign of the source polygon ring: +1 = CCW (left of travel = inside), -1 = CW. */
edgeSign?: number;
} }
export interface MapFeature { export interface MapFeature {

View File

@ -1,12 +1,12 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router'; import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../domain/guards/auth.guard'; import { AuthGuard } from '../domain/guards/auth.guard';
import { ChangelogComponent } from './changelog.component'; import { ReleaseNotesComponent } from './release-notes.component';
const routes: Routes = [ const routes: Routes = [
{ {
path: '', path: '',
component: ChangelogComponent, component: ReleaseNotesComponent,
canActivate: [AuthGuard] canActivate: [AuthGuard]
} }
]; ];
@ -15,4 +15,4 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],
exports: [RouterModule] exports: [RouterModule]
}) })
export class ChangelogRoutingModule { } export class ReleaseNotesRoutingModule { }

View File

@ -0,0 +1,212 @@
.changelog-empty {
padding: 2rem 1rem;
color: #666;
font-style: italic;
}
.changelog-releases-layout {
display: flex;
gap: 0;
align-items: stretch;
background: none;
}
.changelog-left-column {
flex: 0 0 270px;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: flex-start;
position: sticky;
top: 9.5vh;
max-height: 100vh;
overflow-y: auto;
background: none;
box-sizing: border-box;
}
.changelog-releases-wrapper {
/* width controlled by parent column */
}
.changelog-toc-wrapper {
/* width controlled by parent column */
}
/* Ensure PrimeNG p-panel custom elements stretch to full column width */
:host ::ng-deep .changelog-left-column p-panel {
display: block;
}
/* Make the release content panel fill the column and scroll only inside */
:host ::ng-deep .changelog-releases-content p-panel,
:host ::ng-deep .changelog-releases-content .ui-panel {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
:host ::ng-deep .changelog-releases-content .ui-panel-content-wrapper {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
:host ::ng-deep .changelog-releases-content .ui-panel-content {
height: 100%;
overflow-y: auto;
box-sizing: border-box;
}
/* Collapse/expand toggle button */
.changelog-left-toggle {
position: sticky;
top: calc(50vh - 1rem);
align-self: flex-start;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
border: 0;
background: #4CAF50;
cursor: pointer;
border-radius: 4px 0 0 4px;
color: #ffffff;
font-size: 0.85rem;
padding: 0.4rem 0;
box-shadow: -2px 0 6px rgba(0, 0, 0, 0.35);
transition: background 0.2s ease;
z-index: 2;
margin-left: -0.75rem;
}
.changelog-left-toggle--hidden {
margin-left: 0;
border-radius: 0 4px 4px 0;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.35);
}
.changelog-left-toggle:hover {
background: #2E7D32;
}
/* Resize handle between left column and content */
.changelog-left-resize {
flex: 0 0 3px;
cursor: col-resize;
background: none;
position: relative;
align-self: stretch;
z-index: 1;
}
.changelog-left-resize::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 2px;
right: 2px;
background: transparent;
border-radius: 3px;
transition: background 0.15s;
}
.changelog-left-resize,
.changelog-left-resize {
background: #8fa3bb;
}
.changelog-releases-content {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
background: #ffffff;
height: 85vh;
overflow: hidden;
}
.changelog-releases-list {
list-style: none;
padding: 0;
margin: 0;
}
.changelog-releases-list li {
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.changelog-releases-item--active > a {
font-weight: 700;
color: #1a4f7a;
}
.changelog-releases-latest-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
background: #2e7d32;
color: #fff;
border-radius: 3px;
padding: 0 0.35rem;
margin-left: 0.35rem;
vertical-align: middle;
line-height: 1.5;
}
.changelog-releases-group {
list-style: none;
}
.changelog-releases-group-header {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.3rem;
font-weight: 600;
color: #3a5068;
text-decoration: none;
padding: 0.35rem 0;
cursor: pointer;
}
.changelog-releases-group-header:hover {
color: #1a4f7a;
}
.changelog-releases-group-icon {
font-size: 0.75rem;
line-height: 1;
}
.changelog-releases-group-list {
padding-left: 1rem;
}
@media (max-width: 640px) {
.changelog-releases-layout {
flex-direction: column;
}
.changelog-left-column {
flex-basis: auto !important;
max-width: none !important;
width: 100%;
position: static;
max-height: none;
overflow-y: visible;
}
.changelog-left-toggle {
display: none;
}
.changelog-left-resize {
display: none;
}
}

View File

@ -0,0 +1,112 @@
<div class="ui-g">
<div class="ui-g-12">
<div class="card card-w-title">
<div *ngIf="loading" style="text-align:center; padding: 2rem;">
<p-progressSpinner></p-progressSpinner>
</div>
<div *ngIf="!loading && !sections.length" class="changelog-empty" i18n="@@noReleaseNotes">
No release notes have been uploaded yet.
</div>
<div *ngIf="!loading && sections.length" class="changelog-releases-layout">
<!-- Left column: Release Notes + TOC -->
<div
*ngIf="leftColumnVisible"
class="changelog-left-column"
[style.flex-basis.px]="leftColumnWidth"
[style.max-width.px]="leftColumnWidth">
<div class="changelog-releases-wrapper">
<p-panel header="Release Notes" i18n-header="@@releaseNotes" [toggleable]="true">
<ul class="changelog-releases-list">
<!-- Latest release (top-level) -->
<ng-container *ngIf="latestSectionIndex !== -1">
<li [class.changelog-releases-item--active]="latestSectionIndex === activeSectionIndex">
<a href="#" (click)="selectSection($event, latestSectionIndex)">
{{ sections[latestSectionIndex].title }}
<span class="changelog-releases-latest-badge" i18n="@@latest">Latest</span>
</a>
</li>
</ng-container>
<!-- Previous Releases collapsible group -->
<li *ngIf="sections.length > 1" class="changelog-releases-group">
<a href="#" class="changelog-releases-group-header" (click)="togglePreviousReleases($event)">
<span class="changelog-releases-group-icon">{{ previousReleasesExpanded ? '&#x25BE;' : '&#x25B8;' }}</span>
<span i18n="@@previousReleases">Previous Releases</span>
</a>
<ul *ngIf="previousReleasesExpanded" class="changelog-releases-list changelog-releases-group-list">
<li
*ngFor="let section of sections; let i = index"
[class.changelog-releases-item--active]="i === activeSectionIndex"
[style.display]="i === latestSectionIndex ? 'none' : ''">
<a href="#" (click)="selectSection($event, i)">{{ section.title }}</a>
</li>
</ul>
</li>
<!-- No latest marked: show all flat -->
<ng-container *ngIf="latestSectionIndex === -1">
<li
*ngFor="let section of sections; let i = index"
[class.changelog-releases-item--active]="i === activeSectionIndex">
<a href="#" (click)="selectSection($event, i)">{{ section.title }}</a>
</li>
</ng-container>
</ul>
</p-panel>
</div>
<!-- Table of Contents p-panel (populated from markdown-viewer output) -->
<div *ngIf="tocItems.length" class="changelog-toc-wrapper">
<p-panel header="Table of Contents" i18n-header="@@tableOfContents" [toggleable]="true">
<ul class="changelog-releases-list">
<li *ngFor="let item of tocItems">
<a href="#" (click)="scrollToContent($event, item.anchorId)">{{ item.label }}</a>
</li>
</ul>
</p-panel>
</div>
</div><!-- end .changelog-left-column -->
<!-- Toggle button: always in the flex row, sticky at 50vh -->
<button
type="button"
class="changelog-left-toggle"
[class.changelog-left-toggle--hidden]="!leftColumnVisible"
(click)="toggleLeftColumn()"
[title]="leftColumnVisible ? 'Hide sidebar' : 'Show sidebar'">
<span>{{ leftColumnVisible ? '&#x276E;' : '&#x276F;' }}</span>
</button>
<!-- Resize handle -->
<div
*ngIf="leftColumnVisible"
class="changelog-left-resize"
(mousedown)="onResizeStart($event)">
</div>
<!-- Active release content -->
<div class="changelog-releases-content" *ngIf="sections.length">
<p-panel
[header]="sections[activeSectionIndex].title"
[toggleable]="true"
styleClass="changelog-panel">
<app-markdown-viewer
[markdown]="activeMarkdown"
[showFindBar]="true"
(tocItemsChange)="tocItems = $event">
</app-markdown-viewer>
</p-panel>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,121 @@
import { HttpClient } from '@angular/common/http';
import { Component, HostListener, OnInit, ViewChild } from '@angular/core';
import { forkJoin } from 'rxjs';
import { MarkdownViewerComponent } from '../shared/markdown-viewer/markdown-viewer.component';
interface ReleaseManifestEntry {
fileName: string;
title: string;
ver: string;
}
interface ReleaseSection {
title: string;
markdown: string;
}
@Component({
selector: 'app-release-notes',
templateUrl: './release-notes.component.html',
styleUrls: ['./release-notes.component.css']
})
export class ReleaseNotesComponent implements OnInit {
@ViewChild(MarkdownViewerComponent) markdownViewer?: MarkdownViewerComponent;
private readonly releasesApiBase = '/releases';
private readonly releasesStaticBase = '/releases';
sections: ReleaseSection[] = [];
loading = false;
activeSectionIndex = 0;
latestSectionIndex = -1;
previousReleasesExpanded = false;
tocItems: { label: string; anchorId: string }[] = [];
leftColumnVisible = true;
leftColumnWidth = 270;
private isResizing = false;
private resizeStartX = 0;
private resizeStartWidth = 0;
constructor(private readonly http: HttpClient) {}
ngOnInit(): void {
this.loadManifest();
}
get activeMarkdown(): string {
return this.sections[this.activeSectionIndex]?.markdown ?? '';
}
private loadManifest(): void {
this.loading = true;
this.http.get<ReleaseManifestEntry[]>(this.releasesApiBase).subscribe({
next: (revisions) => {
if (!revisions || revisions.length === 0) {
this.loading = false;
return;
}
const requests = revisions.map((r: ReleaseManifestEntry) =>
this.http.get(`${this.releasesStaticBase}/${encodeURIComponent(r.fileName)}`, { responseType: 'text' })
);
forkJoin(requests).subscribe({
next: (markdownFiles: string[]) => {
this.sections = markdownFiles.map((md: string, i: number) => ({
title: revisions[i].title || revisions[i].fileName.replace(/\.md$/i, ''),
markdown: md
}));
// Server returns entries sorted descending by ver; index 0 is always the latest.
this.latestSectionIndex = revisions.length > 0 ? 0 : -1;
this.activeSectionIndex = 0;
this.loading = false;
},
error: () => { this.loading = false; }
});
},
error: () => { this.loading = false; }
});
}
selectSection(event: MouseEvent, index: number): void {
event.preventDefault();
this.activeSectionIndex = index;
}
scrollToContent(event: MouseEvent, anchorId: string): void {
event.preventDefault();
this.markdownViewer?.scrollToId(anchorId);
}
togglePreviousReleases(event: MouseEvent): void {
event.preventDefault();
this.previousReleasesExpanded = !this.previousReleasesExpanded;
}
toggleLeftColumn(): void {
this.leftColumnVisible = !this.leftColumnVisible;
}
onResizeStart(event: MouseEvent): void {
this.isResizing = true;
this.resizeStartX = event.clientX;
this.resizeStartWidth = this.leftColumnWidth;
event.preventDefault();
}
@HostListener('document:mousemove', ['$event'])
onResizeMove(event: MouseEvent): void {
if (this.isResizing) {
const delta = event.clientX - this.resizeStartX;
this.leftColumnWidth = Math.max(160, Math.min(480, this.resizeStartWidth + delta));
}
}
@HostListener('document:mouseup')
onResizeEnd(): void {
this.isResizing = false;
}
}

View File

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { PanelModule } from 'primeng/panel';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { AppSharedModule } from '../shared/app-shared.module';
import { ReleaseNotesRoutingModule } from './release-notes-routing.module';
import { ReleaseNotesComponent } from './release-notes.component';
@NgModule({
imports: [
AppSharedModule,
ReleaseNotesRoutingModule,
PanelModule,
ProgressSpinnerModule
],
declarations: [ReleaseNotesComponent]
})
export class ReleaseNotesModule { }

View File

@ -181,17 +181,17 @@
(click)="openNewDialog()"> (click)="openNewDialog()">
</button> </button>
<button type="button" pButton icon="ui-icon-refresh" <button type="button" pButton icon="ui-icon-refresh"
[disabled]="!selectedKey" [disabled]="!keys.length || !selectedKey"
i18n-label="@@regenerateKey" label="Regenerate" i18n-label="@@regenerateKey" label="Regenerate"
(click)="confirmRegenerate(selectedKey)"> (click)="confirmRegenerate(selectedKey)">
</button> </button>
<button *ngIf="isAdmin" type="button" pButton icon="ui-icon-block" <button *ngIf="isAdmin" type="button" pButton icon="ui-icon-block"
[disabled]="!selectedKey || !selectedKey.active" [disabled]="!keys.length || !selectedKey || !selectedKey.active"
i18n-label="@@revokeKey" label="Revoke" i18n-label="@@revokeKey" label="Revoke"
(click)="confirmRevoke(selectedKey)"> (click)="confirmRevoke(selectedKey)">
</button> </button>
<button type="button" pButton icon="ui-icon-trash" <button type="button" pButton icon="ui-icon-trash"
[disabled]="!selectedKey" [disabled]="!keys.length || !selectedKey"
i18n-label="@@deleteKey" label="Delete" i18n-label="@@deleteKey" label="Delete"
(click)="confirmDelete(selectedKey)"> (click)="confirmDelete(selectedKey)">
</button> </button>

View File

@ -45,6 +45,7 @@ export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestro
cols: any[] = []; cols: any[] = [];
expandedRows: { [id: string]: boolean } = {}; expandedRows: { [id: string]: boolean } = {};
selectedKey: ApiKey | null = null; selectedKey: ApiKey | null = null;
keys: ApiKey[] = [];
showNewDialog = false; showNewDialog = false;
createdAtFilter: Date | null = null; createdAtFilter: Date | null = null;
@ -81,6 +82,8 @@ export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestro
map(([keys, filters]) => this.applyFilters(keys, filters)) map(([keys, filters]) => this.applyFilters(keys, filters))
); );
this.filteredKeys$.pipe(takeUntil(this.destroy$)).subscribe(k => { this.keys = k; });
this.isAdmin = this.authSvc.hasRole([RoleIds.ADMIN]); this.isAdmin = this.authSvc.hasRole([RoleIds.ADMIN]);
this.isMasterAccount = this.authSvc.hasRole([RoleIds.APP]); this.isMasterAccount = this.authSvc.hasRole([RoleIds.APP]);
@ -223,6 +226,7 @@ export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestro
header: $localize`:@@deleteKey:Delete Key`, header: $localize`:@@deleteKey:Delete Key`,
icon: 'pi pi-trash', icon: 'pi pi-trash',
accept: () => { accept: () => {
this.selectedKey = null;
this.store.dispatch(ApiKeyActions.deleteApiKey({ keyId: key._id, ownerId: this.ownerId })); this.store.dispatch(ApiKeyActions.deleteApiKey({ keyId: key._id, ownerId: this.ownerId }));
} }
}); });

View File

@ -1,7 +1,6 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { SettingsRoutingModule } from './settings-routing.module'; import { SettingsRoutingModule } from './settings-routing.module';
import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component'; import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component';
@ -30,7 +29,6 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner';
CommonModule, CommonModule,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
HttpClientModule,
SettingsRoutingModule, SettingsRoutingModule,
AppSharedModule, AppSharedModule,
// PrimeNG // PrimeNG

View File

@ -9,6 +9,7 @@ import { DropdownModule } from 'primeng/dropdown';
import { CheckboxModule } from 'primeng/checkbox'; import { CheckboxModule } from 'primeng/checkbox';
import { KeyFilterModule } from 'primeng/keyfilter'; import { KeyFilterModule } from 'primeng/keyfilter';
import { PanelModule } from 'primeng/panel'; import { PanelModule } from 'primeng/panel';
import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { MessagesModule } from 'primeng/messages'; import { MessagesModule } from 'primeng/messages';
import { MessageModule } from 'primeng/message'; import { MessageModule } from 'primeng/message';
import { RadioButtonModule } from 'primeng/radiobutton'; import { RadioButtonModule } from 'primeng/radiobutton';
@ -78,12 +79,13 @@ import { PromoLabelComponent } from './promo-label/promo-label.component';
import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component'; import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component';
import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component'; import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component';
import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.component'; import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.component';
import { MarkdownViewerComponent } from './markdown-viewer/markdown-viewer.component';
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule, CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule,
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule, ProgressSpinnerModule, MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule,
MultiSelectModule, FormsModule MultiSelectModule, FormsModule
], ],
declarations: [ declarations: [
@ -93,7 +95,7 @@ import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.componen
JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe, JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent, DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent,
PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent, PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
DynamicFilterComponent DynamicFilterComponent, MarkdownViewerComponent
], ],
exports: [ exports: [
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule, FormsModule, CommonModule, GlobalModule, SharedModule, ReactiveFormsModule, FormsModule,
@ -105,7 +107,7 @@ import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.componen
DebounceDirective, UnitIdUniqueDirective, DebounceDirective, UnitIdUniqueDirective,
ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent,
PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent, PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent,
DynamicFilterComponent DynamicFilterComponent, MarkdownViewerComponent
], ],
providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe] providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe]
}) })

View File

@ -316,7 +316,10 @@ export class MapBaseComp extends BaseComp implements OnDestroy {
color: props.color, color: props.color,
area: parseFloat(props.area), area: parseFloat(props.area),
appRate: parseFloat(props.appRate), appRate: parseFloat(props.appRate),
width: NumUtils.round(props.width, 1) width: NumUtils.round(props.width, 1),
offset: props.offset !== undefined ? Number(props.offset) : undefined,
edgeSide: props.edgeSide,
edgeSign: props.edgeSign !== undefined ? Number(props.edgeSign) : undefined
}; };
if (item.type === ITEM.SPRAY || item.type === ITEM.XCL) { if (item.type === ITEM.SPRAY || item.type === ITEM.XCL) {
if (!item.area) if (!item.area)
@ -415,4 +418,7 @@ export interface MapItem {
client?: string; client?: string;
lat?: number; lat?: number;
lon?: number; lon?: number;
offset?: number;
edgeSide?: 'on' | 'inside' | 'outside';
edgeSign?: number;
} }

View File

@ -363,16 +363,21 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
xclArea += dA; xclArea += dA;
} }
} else if (exType === ITEM.BUFFER) { } else if (exType === ITEM.BUFFER) {
// Check whether the buffer zone within (at least one point within) the spray poly to count exlusion
xclLayers[j].updateArea();
const llns = xclLayers[j].getLatLngs(); const llns = xclLayers[j].getLatLngs();
if (llns.length) { if (llns.length) {
let ll; const firstEl = llns[0];
for (let k = 0; k < 1; k++) { if (Array.isArray(firstEl)) {
ll = llns[k]; // Polygon-type buffer (e.g. edge buffer zone) — use turf.intersect for accuracy
if (turf.booleanPointInPolygon([ll.lng, ll.lat], sprayPoly)) { const diff = turf.intersect(sprayPoly, xclPoly);
if (diff) {
const dA = turf.area(diff);
if (dA) xclArea += dA;
}
} else {
// L.Corridor buffer — use corridor's own area calculation
xclLayers[j].updateArea();
if (turf.booleanPointInPolygon([firstEl.lng, firstEl.lat], sprayPoly)) {
xclArea += xclLayers[j].getArea(); xclArea += xclLayers[j].getArea();
break;
} }
} }
} }
@ -446,7 +451,7 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
this.postTypeChanged(e); this.postTypeChanged(e);
} }
protected getDefaultName(layer) { protected getDefaultName(layer, extraOffset = 0) {
if (!layer.feature || !layer.feature.properties) { if (!layer.feature || !layer.feature.properties) {
return ''; return '';
} }
@ -479,7 +484,7 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
} }
} }
} }
number = layers.filter(l => (<any>l).feature.properties.type === type).length; number = layers.filter(l => (<any>l).feature.properties.type === type).length + extraOffset;
} }
return `${name.trim()}_${NumUtils.padZero(number, 2)}`; return `${name.trim()}_${NumUtils.padZero(number, 2)}`;
} }
@ -555,7 +560,10 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
else { else {
if (type === ITEM.BUFFER) { if (type === ITEM.BUFFER) {
this.map.fitBounds((<any>layer).getBounds(), GC.fbOps); this.map.fitBounds((<any>layer).getBounds(), GC.fbOps);
this.selItem = layer.openTooltip(); layer.openTooltip();
// Use a proxy so cleanup calls closeTooltip() but never removeLayer()
// (instanceof L.Polygon would match feature buffers and wrongly remove them)
this.selItem = { closeTooltip: () => layer.closeTooltip() };
} else { } else {
setTimeout(() => this.map.setView((<any>layer).getLatLng(), Math.min(GC.MAX_ZOOM_ITEM, this.map.getZoom())), 200); setTimeout(() => this.map.setView((<any>layer).getLatLng(), Math.min(GC.MAX_ZOOM_ITEM, this.map.getZoom())), 200);
if (!layer.isTooltipOpen()) if (!layer.isTooltipOpen())

View File

@ -105,6 +105,14 @@
margin: 0; margin: 0;
} }
.filter-field-header-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
min-height: 2rem;
}
.filter-field-header .remove-btn { .filter-field-header .remove-btn {
background: transparent !important; background: transparent !important;
border: none !important; border: none !important;

View File

@ -26,9 +26,11 @@
<!-- Header: label + remove --> <!-- Header: label + remove -->
<div class="filter-field-header"> <div class="filter-field-header">
<label>{{ filter.definition.label }}</label> <label>{{ filter.definition.label }}</label>
<button pButton type="button" icon="pi pi-times" class="ui-button-text remove-btn" <span class="filter-field-header-action">
(click)="removeFilter(filter.id)"> <button *ngIf="isFilterRemovable(filter)" pButton type="button" icon="pi pi-times" class="ui-button-text remove-btn"
</button> (click)="removeFilter(filter.id)">
</button>
</span>
</div> </div>
<div class="filter-field-body"> <div class="filter-field-body">
@ -112,15 +114,17 @@
</p-dropdown> </p-dropdown>
<ng-container *ngIf="isDatePresetCustom(filter.id)"> <ng-container *ngIf="isDatePresetCustom(filter.id)">
<div class="date-cal-anchor" style="margin-top: 0.25rem;"> <div class="date-cal-anchor" style="margin-top: 0.25rem;">
<div class="date-input-row" (click)="openCal(filter.id, false)"> <div class="date-input-row" (click)="openCal(filter.id, true)">
<i class="pi pi-calendar date-input-icon"></i> <i class="pi pi-calendar date-input-icon"></i>
<span *ngIf="!filter.value" class="date-placeholder" i18n="@@selectDate">Select Date...</span> <span *ngIf="!filter.value || !filter.value[0]" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
<span *ngIf="filter.value">{{ filter.value | date:'shortDate' }}</span> <span *ngIf="filter.value && filter.value[0] && !filter.value[1]">{{ filter.value[0] | date:'shortDate' }}</span>
<i *ngIf="filter.value" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i> <span *ngIf="filter.value && filter.value[0] && filter.value[1]">{{ filter.value[0] | date:'shortDate' }} {{ filter.value[1] | date:'shortDate' }}</span>
<i *ngIf="filter.value && filter.value[0]" class="pi pi-times date-clear-btn" (click)="clearDate($event, filter)"></i>
</div> </div>
<p-calendar [attr.data-filter-cal]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true" <p-calendar [attr.data-filter-cal-range]="filter.id" [(ngModel)]="filter.value" [locale]="locale" [showIcon]="true"
[dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()" [dateFormat]="locale?.dateFormat || 'mm/dd/yy'" (onSelect)="onValueChange()"
(onClearClick)="onValueChange()" [showButtonBar]="true"> (onClearClick)="onValueChange()" [showButtonBar]="true"
selectionMode="range" [readonlyInput]="true">
</p-calendar> </p-calendar>
</div> </div>
</ng-container> </ng-container>

View File

@ -14,6 +14,7 @@ export interface FilterDefinition {
label: string; label: string;
dataType: FilterDataType; dataType: FilterDataType;
options?: SelectItem[]; options?: SelectItem[];
removable?: boolean;
} }
export type FilterOperator = 'and' | 'or'; export type FilterOperator = 'and' | 'or';
@ -80,10 +81,14 @@ export function buildFilterQuery(activeFilters: ActiveFilter[]): Record<string,
if (f.definition.dataType === 'date' && f.valueOperator === 'range' if (f.definition.dataType === 'date' && f.valueOperator === 'range'
&& (!Array.isArray(f.value) || f.value[0] == null)) { continue; } && (!Array.isArray(f.value) || f.value[0] == null)) { continue; }
if (f.definition.dataType === 'date-preset' && f.value == null) { continue; } if (f.definition.dataType === 'date-preset' && f.value == null) { continue; }
if (f.definition.dataType === 'date-preset' && Array.isArray(f.value) && !f.value[0]) { continue; }
const hasValueOperator = VALUE_OPERATOR_OPTIONS[f.definition.dataType]?.length > 0; const hasValueOperator = VALUE_OPERATOR_OPTIONS[f.definition.dataType]?.length > 0;
const queryValue = (f.definition.dataType === 'date-preset' && Array.isArray(f.value))
? f.value.filter((v: any) => v != null)
: f.value;
query[f.definition.key] = { query[f.definition.key] = {
value: f.value, value: queryValue,
operator: f.operator, operator: f.operator,
...(hasValueOperator ? { valueOperator: f.valueOperator } : {}), ...(hasValueOperator ? { valueOperator: f.valueOperator } : {}),
dataType: f.definition.dataType, dataType: f.definition.dataType,
@ -101,7 +106,7 @@ export function buildFilterQuery(activeFilters: ActiveFilter[]): Record<string,
export class DynamicFilterComponent implements OnInit, OnChanges { export class DynamicFilterComponent implements OnInit, OnChanges {
@Input() filterDefinitions: FilterDefinition[] = []; @Input() filterDefinitions: FilterDefinition[] = [];
@Input() locale: any = {}; @Input() locale: any = {};
@Input() stateKey: string; @Input() stateKey?: string;
@Input() defaultFilters: Array<{ key: string; value: any }> = []; @Input() defaultFilters: Array<{ key: string; value: any }> = [];
@Input() showSearch = true; @Input() showSearch = true;
@Input() autoSaveOnChange = false; @Input() autoSaveOnChange = false;
@ -180,6 +185,9 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
} }
removeFilter(id: number): void { removeFilter(id: number): void {
const filter = this.activeFilters.find(f => f.id === id);
if (!filter || !this.isFilterRemovable(filter)) { return; }
this.activeFilters = this.activeFilters.filter(f => f.id !== id); this.activeFilters = this.activeFilters.filter(f => f.id !== id);
this.datePresetSelected.delete(id); this.datePresetSelected.delete(id);
this.buildAvailableFilters(); this.buildAvailableFilters();
@ -205,11 +213,17 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
} }
clearAll(): void { clearAll(): void {
this.activeFilters = []; this.activeFilters = this.activeFilters
.filter((filter: ActiveFilter) => !this.isFilterRemovable(filter))
.map((filter: ActiveFilter) => this.resetFilter(filter));
this.selectedFilterKey = null; this.selectedFilterKey = null;
this.datePresetSelected.clear(); this.datePresetSelected.clear();
this.activeFilters.forEach((filter: ActiveFilter) => {
if (filter.definition.dataType === 'date-preset' && filter.value != null) {
this.datePresetSelected.set(filter.id, filter.value);
}
});
this.buildAvailableFilters(); this.buildAvailableFilters();
this.clearState();
this.emitChange(); this.emitChange();
this.submit(); this.submit();
} }
@ -235,6 +249,10 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
return this.datePresetSelected.get(filterId) === 'custom'; return this.datePresetSelected.get(filterId) === 'custom';
} }
isFilterRemovable(filter: ActiveFilter): boolean {
return filter.definition.removable !== false;
}
openCal(filterId: number, isRange: boolean): void { openCal(filterId: number, isRange: boolean): void {
const attr = isRange ? `data-filter-cal-range` : `data-filter-cal`; const attr = isRange ? `data-filter-cal-range` : `data-filter-cal`;
const calHost = this.el.nativeElement.querySelector(`[${attr}="${filterId}"]`); const calHost = this.el.nativeElement.querySelector(`[${attr}="${filterId}"]`);
@ -276,13 +294,18 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
case 'select': return null; case 'select': return null;
case 'select-multi': return []; case 'select-multi': return [];
case 'date': return op === 'range' ? null : null; case 'date': return op === 'range' ? null : null;
case 'date-preset': return '1m'; case 'date-preset': return null;
default: return null; default: return null;
} }
} }
private saveState(): void { private saveState(): void {
if (!this.stateKey) { return; } if (!this.stateKey) { return; }
if (!this.activeFilters.length) {
this.clearState();
return;
}
const state = this.activeFilters.map(f => ({ const state = this.activeFilters.map(f => ({
key: f.definition.key, key: f.definition.key,
value: f.value, value: f.value,
@ -303,19 +326,13 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
for (const df of this.defaultFilters) { for (const df of this.defaultFilters) {
const def = this.filterDefinitions.find(f => f.key === df.key); const def = this.filterDefinitions.find(f => f.key === df.key);
if (!def) { continue; } if (!def) { continue; }
const defaultOp = DEFAULT_VALUE_OPERATOR[def.dataType]; const filter = this.createFilter(def, df.value);
const filter: ActiveFilter = {
id: this.nextId++,
definition: def,
value: df.value,
operator: 'and',
valueOperator: defaultOp
};
this.activeFilters.push(filter); this.activeFilters.push(filter);
if (def.dataType === 'date-preset') { if (def.dataType === 'date-preset') {
this.datePresetSelected.set(filter.id, df.value); this.datePresetSelected.set(filter.id, df.value);
} }
} }
this.ensureRequiredFilters();
if (this.activeFilters.length) { if (this.activeFilters.length) {
this.buildAvailableFilters(); this.buildAvailableFilters();
this.submit(); this.submit();
@ -346,9 +363,7 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
if (!def) { continue; } if (!def) { continue; }
const filter: ActiveFilter = { const filter: ActiveFilter = {
id: this.nextId++, ...this.createFilter(def, this.deserializeValue(entry.value, def.dataType, entry.valueOperator)),
definition: def,
value: this.deserializeValue(entry.value, def.dataType, entry.valueOperator),
operator: entry.operator || 'and', operator: entry.operator || 'and',
valueOperator: entry.valueOperator || DEFAULT_VALUE_OPERATOR[def.dataType] valueOperator: entry.valueOperator || DEFAULT_VALUE_OPERATOR[def.dataType]
}; };
@ -359,12 +374,55 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
} }
} }
this.ensureRequiredFilters();
if (this.activeFilters.length) { if (this.activeFilters.length) {
this.buildAvailableFilters(); this.buildAvailableFilters();
this.submit(); this.submit();
} }
} }
private ensureRequiredFilters(): void {
const activeKeys = new Set(this.activeFilters.map((filter: ActiveFilter) => filter.definition.key));
this.filterDefinitions
.filter((definition: FilterDefinition) => definition.removable === false && !activeKeys.has(definition.key))
.forEach((definition: FilterDefinition) => {
const defaultFilter = this.defaultFilters.find(df => df.key === definition.key);
const filter = this.createFilter(definition, defaultFilter ? defaultFilter.value : undefined);
this.activeFilters.push(filter);
if (definition.dataType === 'date-preset' && filter.value != null) {
this.datePresetSelected.set(filter.id, filter.value);
}
});
}
private createFilter(definition: FilterDefinition, value?: any): ActiveFilter {
const defaultOp = DEFAULT_VALUE_OPERATOR[definition.dataType];
return {
id: this.nextId++,
definition,
value: value !== undefined ? value : this.getDefaultValue(definition, defaultOp),
operator: 'and',
valueOperator: defaultOp
};
}
private resetFilter(filter: ActiveFilter): ActiveFilter {
const defaultFilter = this.defaultFilters.find(df => df.key === filter.definition.key);
const defaultOp = DEFAULT_VALUE_OPERATOR[filter.definition.dataType];
return {
...filter,
value: defaultFilter ? defaultFilter.value : this.getDefaultValue(filter.definition, defaultOp),
operator: 'and',
valueOperator: defaultOp
};
}
private deserializeValue(value: any, dataType: FilterDataType, valueOperator: string): any { private deserializeValue(value: any, dataType: FilterDataType, valueOperator: string): any {
if (value == null) { return value; } if (value == null) { return value; }
if (dataType === 'date' && valueOperator === 'range' && Array.isArray(value)) { if (dataType === 'date' && valueOperator === 'range' && Array.isArray(value)) {
@ -373,6 +431,9 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
if (dataType === 'date' && typeof value === 'string') { if (dataType === 'date' && typeof value === 'string') {
return new Date(value); return new Date(value);
} }
if (dataType === 'date-preset' && Array.isArray(value)) {
return value.map(v => v ? new Date(v) : null);
}
return value; return value;
} }
} }

View File

@ -4,7 +4,7 @@ export enum RoleIds { ADMIN = "0", APP = "1", APP_ADM = "2", CLIENT = "3", OFFIC
export enum JobStatus { NEW = 0, READY = 1, DOWNLOADED = 2, SPRAYED = 3, ARCHIVED = 9 }; export enum JobStatus { NEW = 0, READY = 1, DOWNLOADED = 2, SPRAYED = 3, ARCHIVED = 9 };
export enum Units { OZ = 0, GAL, LB, LIT, KG, /*GR, CC, PT*/ }; export enum Units { OZ = 0, GAL, LB, LIT, KG, /*GR, CC, PT*/ };
export enum DRAW { SPRAY, PIVOT, XCL, WAYPOINT, BUFFER, PLACE, OBSTACLE, ABLINE }; export enum DRAW { SPRAY, PIVOT, XCL, WAYPOINT, BUFFER, PLACE, OBSTACLE, ABLINE, EDGE_BUFFER };
export enum PANE { export enum PANE {
GeoItems = 'GeoItems', SprayZones = 'SprayZones', XCLZones = 'XCLZones', GridLines = 'GridLines', FlightPaths = 'FlightPaths', SprayData = 'SprayData', GeoItems = 'GeoItems', SprayZones = 'SprayZones', XCLZones = 'XCLZones', GridLines = 'GridLines', FlightPaths = 'FlightPaths', SprayData = 'SprayData',
Tracks = 'Tracks', ABLine = 'ABLine', Obstacles = 'Obstacles' Tracks = 'Tracks', ABLine = 'ABLine', Obstacles = 'Obstacles'
@ -628,6 +628,7 @@ export const globals = Object.freeze({
xclZone: $localize`:@@xclZone:Exclusion Zone`, xclZone: $localize`:@@xclZone:Exclusion Zone`,
waypoint: $localize`:@@waypoint:WayPoint`, waypoint: $localize`:@@waypoint:WayPoint`,
bufferZone: $localize`:@@bufferZone:Buffer Zone`, bufferZone: $localize`:@@bufferZone:Buffer Zone`,
edgeBufferZone: $localize`:@@edgeBufferZone:Advanced Buffer Tools`,
placeMark: $localize`:@@placeMark:PlaceMark`, placeMark: $localize`:@@placeMark:PlaceMark`,
obstacle: $localize`:@@obstacle:Obstacle`, obstacle: $localize`:@@obstacle:Obstacle`,
userObstacle: $localize`:@@userObstacle:User Obstacles`, userObstacle: $localize`:@@userObstacle:User Obstacles`,

View File

@ -0,0 +1,284 @@
.markdown-viewer__find-bar {
display: flex;
align-items: center;
gap: 0.5rem;
margin: -0.5em -0.75em 0.75rem;
padding: 0.35rem 0.75em;
background: #f0f3f6;
border: 1px solid #d0d8e4;
border-radius: 0;
flex-shrink: 0;
position: sticky;
top: -0.5em;
z-index: 10;
}
.markdown-viewer__find-input {
flex: 1 1 auto;
min-width: 0;
padding: 0.3rem 0.5rem;
border: 1px solid #b8c3ce;
border-radius: 3px;
font-size: 0.9rem;
outline: none;
}
.markdown-viewer__find-input:focus {
border-color: #5b8db8;
}
.markdown-viewer__find-count {
flex: 0 0 auto;
font-size: 0.82rem;
color: #5a6a7a;
white-space: nowrap;
}
.markdown-viewer__find-nav {
flex: 0 0 auto;
background: none;
border: 1px solid #b8c3ce;
border-radius: 3px;
cursor: pointer;
color: #5a6a7a;
font-size: 0.8rem;
line-height: 1;
padding: 0.15rem 0.35rem;
}
.markdown-viewer__find-nav:hover {
background: #e2e8f0;
color: #2c3e50;
}
.markdown-viewer__find-clear {
flex: 0 0 auto;
background: none;
border: none;
cursor: pointer;
color: #5a6a7a;
font-size: 1rem;
line-height: 1;
padding: 0 0.2rem;
}
.markdown-viewer__find-clear:hover {
color: #c0392b;
}
mark.markdown-viewer__hl {
background: #fff59d;
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
mark.markdown-viewer__hl--active {
background: #f9a825;
outline: 2px solid #e65100;
border-radius: 2px;
}
.markdown-viewer__toc-list {
list-style: none;
padding: 0;
margin: 0;
text-align: left;
}
.markdown-viewer__toc-list li {
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.markdown-viewer__toc-resize {
flex: 0 0 6px;
cursor: col-resize;
background: transparent;
position: relative;
align-self: stretch;
z-index: 1;
}
.markdown-viewer__toc-resize::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 2px;
right: 2px;
background: transparent;
border-radius: 3px;
transition: background 0.15s;
}
.markdown-viewer__toc-resize:hover::after,
.markdown-viewer__toc-resize:active::after {
background: #8fa3bb;
}
.markdown-viewer__toc-toggle {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
border: 0;
background: #4CAF50;
cursor: pointer;
border-radius: 4px 0 0 4px;
color: #ffffff;
font-size: 0.85rem;
padding: 0.4rem 0;
box-shadow: -2px 0 6px rgba(0, 0, 0, 0.35);
transition: background 0.2s ease;
z-index: 1;
}
.markdown-viewer__toc-toggle--hidden {
position: static;
transform: none;
flex: 0 0 auto;
align-self: center;
border-radius: 4px 0 0 4px;
margin-left: 0;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.35);
}
.markdown-viewer__toc-toggle:hover {
background: #2E7D32;
}
.markdown-viewer__content {
flex: 1 1 auto;
min-width: 0;
max-height: 70vh;
overflow: auto;
padding: 0 0.5rem 0 0.75rem;
box-sizing: border-box;
}
.markdown-viewer__content--full {
max-height: none;
}
.markdown-viewer__content:focus {
outline: none;
}
.markdown-viewer__intro {
margin-bottom: 1rem;
}
.markdown-viewer__panel-node {
margin-bottom: 0.75rem;
padding-left: 0.25rem;
}
.markdown-viewer__panel-node:last-child {
margin-bottom: 0;
}
.markdown-viewer__section-title {
font-weight: 600;
padding: 0.35rem 0;
}
.markdown-viewer__panel-content {
margin: 0.2rem 0 0 0.5rem;
}
/* Table borders inside rendered markdown */
:host ::ng-deep .markdown-viewer__body table {
border-collapse: collapse;
width: 100%;
margin: 0.75rem 0;
}
:host ::ng-deep .markdown-viewer__body th,
:host ::ng-deep .markdown-viewer__body td {
border: 1px solid #b0b8c4;
padding: 0.4rem 0.65rem;
text-align: left;
}
:host ::ng-deep .markdown-viewer__body th {
background: #e8ecf0;
font-weight: 600;
}
:host ::ng-deep .markdown-viewer__body tr:nth-child(even) td {
background: #f5f7f9;
}
.markdown-viewer__mermaid {
overflow-x: auto;
margin: 1.5rem 0;
}
.markdown-viewer__mermaid .mermaid {
min-width: fit-content;
}
.markdown-viewer__mermaid svg {
display: block;
max-width: 100%;
height: auto;
}
.markdown-viewer__video {
margin: 1.5rem 0;
}
.markdown-viewer__video-element,
.markdown-viewer__video-frame {
display: block;
width: 100%;
max-width: 100%;
}
.markdown-viewer__video-element {
height: auto;
}
.markdown-viewer__video-frame {
position: relative;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;
}
.markdown-viewer__video-frame iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
.markdown-viewer__video figcaption {
margin-top: 0.5rem;
color: #55606e;
font-size: 0.95rem;
}
@media (max-width: 960px) {
.markdown-viewer__content {
max-height: none;
width: 100%;
padding: 0;
}
.markdown-viewer__panel-node {
margin-left: 0 !important;
padding-left: 0;
}
.markdown-viewer__panel-content {
margin-left: 0;
}
}

View File

@ -0,0 +1,45 @@
<div *ngIf="loading" style="text-align:center; padding: 2rem;">
<p-progressSpinner></p-progressSpinner>
</div>
<ng-container *ngIf="!loading">
<!-- Find bar (shown only when showFindBar is true) -->
<div *ngIf="showFindBar" class="markdown-viewer__find-bar">
<input
type="text"
class="markdown-viewer__find-input"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange($event)"
(keydown)="onSearchKeydown($event)"
placeholder="Find in page..."
i18n-placeholder="@@findInPage">
<span *ngIf="searchQuery" class="markdown-viewer__find-count">
{{ matchCount === 0 ? 'No matches' : (currentMatchIndex + 1) + ' of ' + matchCount }}
</span>
<ng-container *ngIf="searchQuery && matchCount > 0">
<button class="markdown-viewer__find-nav" (click)="navigateMatch(-1)" title="Previous match">&#x25B4;</button>
<button class="markdown-viewer__find-nav" (click)="navigateMatch(1)" title="Next match">&#x25BE;</button>
</ng-container>
<button *ngIf="searchQuery" class="markdown-viewer__find-clear" (click)="clearSearch()" title="Clear">&#x2715;</button>
</div>
<ng-container *ngIf="sections[0]">
<div #markdownContent class="markdown-viewer__body markdown-viewer__content markdown-viewer__content--full">
<div
*ngIf="sections[0].intro"
[attr.id]="sections[0].introAnchorId"
class="markdown-viewer__intro"
[innerHTML]="sections[0].intro"></div>
<div
*ngFor="let panel of sections[0].flatPanels"
[attr.id]="panel.anchorId"
class="markdown-viewer__panel-node">
<div class="markdown-viewer__section-title">{{ panel.header }}</div>
<div
*ngIf="panel.content"
class="markdown-viewer__panel-content"
[innerHTML]="panel.content"></div>
</div>
</div>
</ng-container>
</ng-container>

View File

@ -0,0 +1,513 @@
import { HttpClient } from '@angular/common/http';
import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import * as marked from 'marked';
import mermaid from 'mermaid';
const parseMarkdown: (src: string) => string = (marked as any).marked ?? (marked as any).default ?? (marked as any);
const videoFilePattern = /\.(mp4|webm|ogg)(?:$|[?#])/i;
interface TocItem {
label: string;
anchorId: string;
}
interface MarkdownSection {
header: string;
tocItems: TocItem[];
intro?: SafeHtml;
introAnchorId?: string;
panels: MarkdownContentPanel[];
flatPanels: MarkdownContentPanel[];
}
interface MarkdownContentPanel {
header: string;
anchorId: string;
depth: number;
content?: SafeHtml;
children: MarkdownContentPanel[];
}
interface MarkdownContentPanelBuilder {
level: number;
header: string;
anchorId: string;
bodyLines: string[];
children: MarkdownContentPanelBuilder[];
}
@Component({
selector: 'app-markdown-viewer',
templateUrl: './markdown-viewer.component.html',
styleUrls: ['./markdown-viewer.component.css']
})
export class MarkdownViewerComponent implements OnChanges {
@Input() src?: string;
@Input() markdown?: string;
@Input() showFindBar = false;
@Output() tocItemsChange = new EventEmitter<TocItem[]>();
@ViewChildren('markdownContent') contentPanes!: QueryList<ElementRef<HTMLElement>>;
sections: MarkdownSection[] = [];
loading = false;
searchQuery = '';
matchCount = 0;
currentMatchIndex = 0;
private readonly hlClass = 'markdown-viewer__hl';
private readonly hlActiveClass = 'markdown-viewer__hl--active';
private allMarks: Element[] = [];
constructor(
private readonly http: HttpClient,
private readonly sanitizer: DomSanitizer
) {
mermaid.initialize({ startOnLoad: false });
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.src || changes.markdown) {
this.loadContent();
}
}
private loadContent(): void {
if (this.markdown !== undefined) {
this.sections = [this.buildSection('', this.markdown, 0)];
this.loading = false;
this.tocItemsChange.emit(this.sections[0].tocItems);
this.scheduleMermaidRender();
return;
}
if (!this.src) {
this.sections = [];
this.loading = false;
return;
}
this.loading = true;
this.http.get(this.src, { responseType: 'text' }).subscribe({
next: (md) => {
this.sections = [this.buildSection('', md, 0)];
this.loading = false;
this.tocItemsChange.emit(this.sections[0].tocItems);
this.scheduleMermaidRender();
},
error: () => {
this.sections = [];
this.loading = false;
}
});
}
onSearchChange(query: string): void {
this.highlightMatches(query.trim());
}
onSearchKeydown(event: KeyboardEvent): void {
if (event.key !== 'Enter' || this.matchCount === 0) { return; }
event.preventDefault();
const delta = event.shiftKey ? -1 : 1;
this.navigateMatch(delta);
}
navigateMatch(delta: number): void {
if (this.allMarks.length === 0) { return; }
this.allMarks[this.currentMatchIndex].classList.remove(this.hlActiveClass);
this.currentMatchIndex = (this.currentMatchIndex + delta + this.allMarks.length) % this.allMarks.length;
const active = this.allMarks[this.currentMatchIndex];
active.classList.add(this.hlActiveClass);
active.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
clearSearch(): void {
this.searchQuery = '';
this.highlightMatches('');
}
private highlightMatches(query: string): void {
// Remove existing marks
this.contentPanes.forEach(ref => {
ref.nativeElement.querySelectorAll('mark.' + this.hlClass).forEach((mark: Element) => {
const parent = mark.parentNode;
if (!parent) { return; }
while (mark.firstChild) { parent.insertBefore(mark.firstChild, mark); }
parent.removeChild(mark);
parent.normalize();
});
});
this.allMarks = [];
this.matchCount = 0;
this.currentMatchIndex = 0;
if (!query) { return; }
const lq = query.toLowerCase();
this.contentPanes.forEach(ref => {
const walker = document.createTreeWalker(ref.nativeElement, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
let n: Node | null;
while ((n = walker.nextNode())) { textNodes.push(n as Text); }
for (const textNode of textNodes) {
const text = textNode.textContent || '';
const ltext = text.toLowerCase();
let idx = ltext.indexOf(lq);
if (idx === -1) { continue; }
const frag = document.createDocumentFragment();
let last = 0;
while (idx !== -1) {
if (idx > last) { frag.appendChild(document.createTextNode(text.slice(last, idx))); }
const mark = document.createElement('mark');
mark.className = this.hlClass;
mark.textContent = text.slice(idx, idx + query.length);
frag.appendChild(mark);
this.allMarks.push(mark);
this.matchCount++;
last = idx + query.length;
idx = ltext.indexOf(lq, last);
}
if (last < text.length) { frag.appendChild(document.createTextNode(text.slice(last))); }
textNode.parentNode?.replaceChild(frag, textNode);
}
});
if (this.allMarks.length > 0) {
this.currentMatchIndex = 0;
this.allMarks[0].classList.add(this.hlActiveClass);
this.allMarks[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
scrollToId(anchorId: string): void {
const contentPane = this.contentPanes.first?.nativeElement;
if (!contentPane) { return; }
const target = contentPane.querySelector('#' + anchorId) as HTMLElement | null;
if (!target) { return; }
const offset = target.getBoundingClientRect().top - contentPane.getBoundingClientRect().top;
contentPane.scrollTop += offset - 12;
target.setAttribute('tabindex', '-1');
target.focus();
}
private buildSection(header: string, body: string, sectionIndex: number): MarkdownSection {
const { contentMarkdown } = this.extractTableOfContents(body);
const anchorPrefix = 'markdown-viewer-section-' + sectionIndex + '-';
const structuredContent = this.buildStructuredContent(contentMarkdown, anchorPrefix);
const tocItems: TocItem[] = structuredContent.panels.map(p => ({ label: p.header, anchorId: p.anchorId }));
return {
header,
tocItems,
intro: structuredContent.introHtml,
introAnchorId: structuredContent.introAnchorId,
panels: structuredContent.panels,
flatPanels: this.flattenPanels(structuredContent.panels)
};
}
private buildStructuredContent(contentMarkdown: string, anchorPrefix: string): {
introHtml?: SafeHtml;
introAnchorId?: string;
panels: MarkdownContentPanel[];
} {
const lines = contentMarkdown.split('\n');
const introLines: string[] = [];
const panelBuilders: MarkdownContentPanelBuilder[] = [];
const panelStack: MarkdownContentPanelBuilder[] = [];
const slugCounts: { [slug: string]: number } = {};
let introAnchorId: string | undefined;
for (const line of lines) {
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (!headingMatch) {
if (panelStack.length > 0) {
panelStack[panelStack.length - 1].bodyLines.push(line);
} else {
introLines.push(line);
}
continue;
}
const level = headingMatch[1].length;
const headingMarkdown = headingMatch[2].trim();
const headingText = this.markdownToPlainText(headingMarkdown);
const anchorId = anchorPrefix + this.nextSlug(headingText, slugCounts);
if (level === 1 && panelBuilders.length === 0 && panelStack.length === 0) {
introAnchorId = introAnchorId || anchorId;
introLines.push(line);
continue;
}
if (level < 2) {
if (panelStack.length > 0) {
panelStack[panelStack.length - 1].bodyLines.push(line);
} else {
introLines.push(line);
}
continue;
}
const panelBuilder: MarkdownContentPanelBuilder = {
level,
header: headingText,
anchorId,
bodyLines: [],
children: []
};
while (panelStack.length > 0 && panelStack[panelStack.length - 1].level >= level) {
panelStack.pop();
}
if (panelStack.length === 0) {
panelBuilders.push(panelBuilder);
} else {
panelStack[panelStack.length - 1].children.push(panelBuilder);
}
panelStack.push(panelBuilder);
}
return {
introHtml: introLines.join('\n').trim() ? this.sanitizer.bypassSecurityTrustHtml(this.renderMarkdownHtml(introLines.join('\n').trim())) : undefined,
introAnchorId,
panels: this.buildPanels(panelBuilders, 0)
};
}
private extractTableOfContents(body: string): { tocMarkdown: string | null; contentMarkdown: string } {
const lines = body.split('\n');
const tocHeadingIndex = lines.findIndex((line) => /^##\s+Table of Contents\s*$/i.test(line));
if (tocHeadingIndex === -1) {
return { tocMarkdown: null, contentMarkdown: body };
}
let tocEndIndex = tocHeadingIndex + 1;
let sawListItem = false;
while (tocEndIndex < lines.length) {
const line = lines[tocEndIndex];
if (/^\s*$/.test(line)) {
tocEndIndex += 1;
continue;
}
if (/^\s*---+\s*$/.test(line) && sawListItem) {
tocEndIndex += 1;
break;
}
if (/^\s*(?:[-*+]\s+|\d+\.\s+)/.test(line)) {
sawListItem = true;
tocEndIndex += 1;
continue;
}
if (/^\s{2,}(?:[-*+]\s+|\d+\.\s+)/.test(line) || /^\s{2,}\S/.test(line)) {
tocEndIndex += 1;
continue;
}
if (sawListItem) {
break;
}
tocEndIndex += 1;
}
const tocMarkdown = lines.slice(tocHeadingIndex, tocEndIndex).join('\n').trim();
const contentLines = [
...lines.slice(0, tocHeadingIndex),
...lines.slice(tocEndIndex)
];
return {
tocMarkdown,
contentMarkdown: contentLines.join('\n').trim()
};
}
private renderMarkdownHtml(markdown: string): string {
if (!markdown) {
return '';
}
return this.decorateContentHtml(parseMarkdown(this.preprocessMarkdown(markdown)));
}
private decorateContentHtml(html: string): string {
const root = this.parseHtml(html);
this.replaceMermaidBlocks(root);
return root.innerHTML;
}
private buildPanels(panelBuilders: MarkdownContentPanelBuilder[], depth: number): MarkdownContentPanel[] {
return panelBuilders.map((panelBuilder: MarkdownContentPanelBuilder) => ({
header: panelBuilder.header,
anchorId: panelBuilder.anchorId,
depth,
content: panelBuilder.bodyLines.join('\n').trim()
? this.sanitizer.bypassSecurityTrustHtml(this.renderMarkdownHtml(panelBuilder.bodyLines.join('\n').trim()))
: undefined,
children: this.buildPanels(panelBuilder.children, depth + 1),
}));
}
private flattenPanels(panels: MarkdownContentPanel[]): MarkdownContentPanel[] {
return panels.reduce((flattenedPanels: MarkdownContentPanel[], panel: MarkdownContentPanel) => {
flattenedPanels.push(panel);
if (panel.children.length > 0) {
flattenedPanels.push(...this.flattenPanels(panel.children));
}
return flattenedPanels;
}, []);
}
private replaceMermaidBlocks(root: HTMLElement): void {
Array.from(root.querySelectorAll('pre > code')).forEach((codeElement: Element) => {
const className = codeElement.getAttribute('class') || '';
if (!/(?:^|\s)(?:language|lang)-mermaid(?:\s|$)/.test(className)) {
return;
}
const preElement = codeElement.parentElement;
if (!preElement || preElement.tagName !== 'PRE') {
return;
}
const wrapper = root.ownerDocument.createElement('div');
const diagram = root.ownerDocument.createElement('div');
wrapper.setAttribute('class', 'markdown-viewer__mermaid');
diagram.setAttribute('class', 'mermaid');
diagram.textContent = codeElement.textContent || '';
wrapper.appendChild(diagram);
preElement.parentNode?.replaceChild(wrapper, preElement);
});
}
private scheduleMermaidRender(): void {
setTimeout(() => {
this.renderMermaidDiagrams();
if (this.searchQuery) {
this.highlightMatches(this.searchQuery.trim());
}
});
}
private renderMermaidDiagrams(): void {
if (!this.contentPanes || this.contentPanes.length === 0) {
return;
}
this.contentPanes.forEach((contentPaneRef: ElementRef<HTMLElement>) => {
const diagrams = Array.from(contentPaneRef.nativeElement.querySelectorAll('.mermaid'))
.filter((diagramElement: Element) => !diagramElement.getAttribute('data-processed'));
if (diagrams.length > 0) {
mermaid.init(undefined, diagrams);
}
});
}
private parseHtml(html: string): HTMLElement {
const parser = new DOMParser();
const document = parser.parseFromString(html, 'text/html');
return document.body;
}
private markdownToPlainText(markdown: string): string {
const root = this.parseHtml(parseMarkdown(this.preprocessMarkdown(markdown)));
return (root.textContent || '').trim();
}
private nextSlug(value: string, slugCounts: { [slug: string]: number }): string {
const baseSlug = this.slugify(value);
const occurrenceCount = slugCounts[baseSlug] || 0;
slugCounts[baseSlug] = occurrenceCount + 1;
return occurrenceCount === 0 ? baseSlug : baseSlug + '-' + occurrenceCount;
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
private preprocessMarkdown(markdown: string): string {
return markdown.replace(/^!video(?:\[([^\]]*)\])?\(([^\s)]+(?:\([^\s)]*\)[^\s)]*)*)\)$/gm, (_match, rawTitle, rawUrl) => {
const title = (rawTitle || 'Embedded video').trim();
const url = (rawUrl || '').trim();
if (!url) {
return '';
}
return this.buildVideoEmbedHtml(url, title);
});
}
private buildVideoEmbedHtml(url: string, title: string): string {
const safeUrl = this.escapeHtml(url);
const safeTitle = this.escapeHtml(title);
if (videoFilePattern.test(url)) {
return [
'<figure class="markdown-viewer__video">',
' <video controls preload="metadata" playsinline class="markdown-viewer__video-element">',
' <source src="' + safeUrl + '">',
' <a href="' + safeUrl + '">' + safeTitle + '</a>',
' </video>',
safeTitle ? ' <figcaption>' + safeTitle + '</figcaption>' : '',
'</figure>'
].filter(Boolean).join('\n');
}
return [
'<figure class="markdown-viewer__video markdown-viewer__video--embed">',
' <div class="markdown-viewer__video-frame">',
' <iframe',
' src="' + safeUrl + '"',
' title="' + safeTitle + '"',
' loading="lazy"',
' allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"',
' allowfullscreen',
' referrerpolicy="strict-origin-when-cross-origin">',
' </iframe>',
' </div>',
safeTitle ? ' <figcaption>' + safeTitle + '</figcaption>' : '',
'</figure>'
].filter(Boolean).join('\n');
}
private slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
}

View File

@ -0,0 +1 @@
L.River=L.FeatureGroup.extend({options:{color:"blue",minWidth:1,maxWidth:10,ratio:null},initialize:function(t,i){L.FeatureGroup.prototype.initialize.call(this,[],i);this._latLngs=t;L.setOptions(this,i);this._buildLines(t)},onAdd:function(t){L.FeatureGroup.prototype.onAdd.call(this,t);this._getLength(t);this.setStyle()},_buildLines:function(t){for(var i=0;i<t.length-1;i++){var n=L.polyline([t[i],t[i+1]]);this.addLayer(n)}},_getLength:function(t){var i=this._latLngs,n=0;for(var e=0;e<i.length-1;e++){n+=t.latLngToLayerPoint(i[e]).distanceTo(t.latLngToLayerPoint(i[e+1]))}return this._length=n},setStyle:function(t){this.options=L.extend(this.options,t);var i=this.options,n=this._length,e=this._map,o=this._layers,s=this._points,h=0,r,a;for(var l in o){r=o[l];a=r.getLatLngs();h+=e.latLngToLayerPoint(a[0]).distanceTo(e.latLngToLayerPoint(a[1]));var u=h/n;var d=i.minWidth+i.maxWidth*u;r.setStyle(L.extend({},i,{weight:i.ratio?h/i.ratio:i.minWidth+(i.maxWidth-i.minWidth)*u}))}},setMinWidth:function(t){this.setStyle({minWidth:t})},setMaxWidth:function(t){this.setStyle({maxWidth:t})},getMinWidth:function(t){return this.options.minWidth},getMaxWidth:function(){return this.options.maxWidth},useLength:function(t){L.setOptions(this,{ratio:t});return this},convertToPolyline:function(t){return L.polyline(this._latLngs,t)}});L.river=function(t,i){return new L.River(t,i)};

View File

@ -0,0 +1,826 @@
// Packaging/modules magic dance.
(function (factory) {
var L;
if (typeof define === 'function' && define.amd) {
// AMD
define(['leaflet'], factory);
} else if (typeof module !== 'undefined') {
// Node/CommonJS
L = require('leaflet');
module.exports = factory(L);
} else {
// Browser globals
if (typeof window.L === 'undefined')
throw 'Leaflet must be loaded first';
factory(window.L);
}
}(function (L) {
"use strict";
L.Polyline._flat = L.LineUtil.isFlat || L.Polyline._flat || function (latlngs) {
// true if it's a flat array of latlngs; false if nested
return !L.Util.isArray(latlngs[0]) || (typeof latlngs[0][0] !== 'object' && typeof latlngs[0][0] !== 'undefined');
};
/**
* @fileOverview Leaflet Geometry utilities for distances and linear referencing.
* @name L.GeometryUtil
*/
L.GeometryUtil = L.extend(L.GeometryUtil || {}, {
/**
Shortcut function for planar distance between two {L.LatLng} at current zoom.
@tutorial distance-length
@param {L.Map} map Leaflet map to be used for this method
@param {L.LatLng} latlngA geographical point A
@param {L.LatLng} latlngB geographical point B
@returns {Number} planar distance
*/
distance: function (map, latlngA, latlngB) {
return map.latLngToLayerPoint(latlngA).distanceTo(map.latLngToLayerPoint(latlngB));
},
/**
Shortcut function for planar distance between a {L.LatLng} and a segment (A-B).
@param {L.Map} map Leaflet map to be used for this method
@param {L.LatLng} latlng - The position to search
@param {L.LatLng} latlngA geographical point A of the segment
@param {L.LatLng} latlngB geographical point B of the segment
@returns {Number} planar distance
*/
distanceSegment: function (map, latlng, latlngA, latlngB) {
var p = map.latLngToLayerPoint(latlng),
p1 = map.latLngToLayerPoint(latlngA),
p2 = map.latLngToLayerPoint(latlngB);
return L.LineUtil.pointToSegmentDistance(p, p1, p2);
},
/**
Shortcut function for converting distance to readable distance.
Supports two calling conventions:
- Original: (distance, unit) where unit is 'metric' or 'imperial'
- leaflet-draw: (distance, isMetric, isFeet, isNautical, precision) with boolean args
@param {Number} distance distance in meters to be converted
@param {String|Boolean} unit 'metric'/'imperial' string, or boolean isMetric (true=metric)
@param {Boolean} [isFeet] when not metric, use feet instead of yards
@returns {String} readable distance string
*/
readableDistance: function (distance, unit, isFeet) {
var isMetric;
if (typeof unit === 'boolean') {
// leaflet-draw calls readableDistance(distance, isMetric, isFeet, ...)
isMetric = unit;
} else {
isMetric = (unit !== 'imperial');
}
var distanceStr;
if (isMetric) {
// show metres when distance is < 1km, then show km
if (distance > 1000) {
distanceStr = (distance / 1000).toFixed(2) + ' km';
}
else {
distanceStr = distance.toFixed(1) + ' m';
}
}
else if (isFeet) {
distance *= 3.28084;
if (distance > 5280) {
distanceStr = (distance / 5280).toFixed(2) + ' mi';
}
else {
distanceStr = distance.toFixed(1) + ' ft';
}
}
else {
distance *= 1.09361;
if (distance > 1760) {
distanceStr = (distance / 1760).toFixed(2) + ' miles';
}
else {
distanceStr = distance.toFixed(1) + ' yd';
}
}
return distanceStr;
},
/**
Returns true if the latlng belongs to segment A-B
@param {L.LatLng} latlng - The position to search
@param {L.LatLng} latlngA geographical point A of the segment
@param {L.LatLng} latlngB geographical point B of the segment
@param {?Number} [tolerance=0.2] tolerance to accept if latlng belongs really
@returns {boolean}
*/
belongsSegment: function(latlng, latlngA, latlngB, tolerance) {
tolerance = tolerance === undefined ? 0.2 : tolerance;
var hypotenuse = latlngA.distanceTo(latlngB),
delta = latlngA.distanceTo(latlng) + latlng.distanceTo(latlngB) - hypotenuse;
return delta/hypotenuse < tolerance;
},
/**
* Returns total length of line
* @tutorial distance-length
*
* @param {L.Polyline|Array<L.Point>|Array<L.LatLng>} coords Set of coordinates
* @returns {Number} Total length (pixels for Point, meters for LatLng)
*/
length: function (coords) {
var accumulated = L.GeometryUtil.accumulatedLengths(coords);
return accumulated.length > 0 ? accumulated[accumulated.length-1] : 0;
},
/**
* Returns a list of accumulated length along a line.
* @param {L.Polyline|Array<L.Point>|Array<L.LatLng>} coords Set of coordinates
* @returns {Array<Number>} Array of accumulated lengths (pixels for Point, meters for LatLng)
*/
accumulatedLengths: function (coords) {
if (typeof coords.getLatLngs == 'function') {
coords = coords.getLatLngs();
}
if (coords.length === 0)
return [];
var total = 0,
lengths = [0];
for (var i = 0, n = coords.length - 1; i< n; i++) {
total += coords[i].distanceTo(coords[i+1]);
lengths.push(total);
}
return lengths;
},
/**
Returns the closest point of a {L.LatLng} on the segment (A-B)
@tutorial closest
@param {L.Map} map Leaflet map to be used for this method
@param {L.LatLng} latlng - The position to search
@param {L.LatLng} latlngA geographical point A of the segment
@param {L.LatLng} latlngB geographical point B of the segment
@returns {L.LatLng} Closest geographical point
*/
closestOnSegment: function (map, latlng, latlngA, latlngB) {
var maxzoom = map.getMaxZoom();
if (maxzoom === Infinity)
maxzoom = map.getZoom();
var p = map.project(latlng, maxzoom),
p1 = map.project(latlngA, maxzoom),
p2 = map.project(latlngB, maxzoom),
closest = L.LineUtil.closestPointOnSegment(p, p1, p2);
return map.unproject(closest, maxzoom);
},
/**
Returns the closest point of a {L.LatLng} on a {L.Circle}
@tutorial closest
@param {L.LatLng} latlng - The position to search
@param {L.Circle} circle - A Circle defined by a center and a radius
@returns {L.LatLng} Closest geographical point on the circle circumference
*/
closestOnCircle: function (circle, latLng) {
const center = circle.getLatLng();
const circleRadius = circle.getRadius();
const radius = typeof circleRadius === 'number' ? circleRadius : circleRadius.radius;
const x = latLng.lng;
const y = latLng.lat;
const cx = center.lng;
const cy = center.lat;
// dx and dy is the vector from the circle's center to latLng
const dx = x - cx;
const dy = y - cy;
// distance between the point and the circle's center
const distance = Math.sqrt(dx * dx + dy * dy)
// Calculate the closest point on the circle by adding the normalized vector to the center
const tx = cx + (dx / distance) * radius;
const ty = cy + (dy / distance) * radius;
return new L.LatLng(ty, tx);
},
/**
Returns the closest latlng on layer.
Accept nested arrays
@tutorial closest
@param {L.Map} map Leaflet map to be used for this method
@param {Array<L.LatLng>|Array<Array<L.LatLng>>|L.PolyLine|L.Polygon} layer - Layer that contains the result
@param {L.LatLng} latlng - The position to search
@param {?boolean} [vertices=false] - Whether to restrict to path vertices.
@returns {L.LatLng} Closest geographical point or null if layer param is incorrect
*/
closest: function (map, layer, latlng, vertices) {
var latlngs,
mindist = Infinity,
result = null,
i, n, distance, subResult;
if (layer instanceof Array) {
// if layer is Array<Array<T>>
if (layer[0] instanceof Array && typeof layer[0][0] !== 'number') {
// if we have nested arrays, we calc the closest for each array
// recursive
for (i = 0; i < layer.length; i++) {
subResult = L.GeometryUtil.closest(map, layer[i], latlng, vertices);
if (subResult && subResult.distance < mindist) {
mindist = subResult.distance;
result = subResult;
}
}
return result;
} else if (layer[0] instanceof L.LatLng
|| typeof layer[0][0] === 'number'
|| typeof layer[0].lat === 'number') { // we could have a latlng as [x,y] with x & y numbers or {lat, lng}
layer = L.polyline(layer);
} else {
return result;
}
}
// if we don't have here a Polyline, that means layer is incorrect
// see https://github.com/makinacorpus/Leaflet.GeometryUtil/issues/23
if (! ( layer instanceof L.Polyline ) )
return result;
// deep copy of latlngs
latlngs = JSON.parse(JSON.stringify(layer.getLatLngs().slice(0)));
// add the last segment for L.Polygon
if (layer instanceof L.Polygon) {
// add the last segment for each child that is a nested array
var addLastSegment = function(latlngs) {
if (L.Polyline._flat(latlngs)) {
latlngs.push(latlngs[0]);
} else {
for (var i = 0; i < latlngs.length; i++) {
addLastSegment(latlngs[i]);
}
}
};
addLastSegment(latlngs);
}
// we have a multi polygon / multi polyline / polygon with holes
// use recursive to explore and return the good result
if ( ! L.Polyline._flat(latlngs) ) {
for (i = 0; i < latlngs.length; i++) {
// if we are at the lower level, and if we have a L.Polygon, we add the last segment
subResult = L.GeometryUtil.closest(map, latlngs[i], latlng, vertices);
if (subResult.distance < mindist) {
mindist = subResult.distance;
result = subResult;
}
}
return result;
} else {
// Lookup vertices
if (vertices) {
for(i = 0, n = latlngs.length; i < n; i++) {
var ll = latlngs[i];
distance = L.GeometryUtil.distance(map, latlng, ll);
if (distance < mindist) {
mindist = distance;
result = ll;
result.distance = distance;
}
}
return result;
}
// Keep the closest point of all segments
for (i = 0, n = latlngs.length; i < n-1; i++) {
var latlngA = latlngs[i],
latlngB = latlngs[i+1];
distance = L.GeometryUtil.distanceSegment(map, latlng, latlngA, latlngB);
if (distance <= mindist) {
mindist = distance;
result = L.GeometryUtil.closestOnSegment(map, latlng, latlngA, latlngB);
result.distance = distance;
}
}
return result;
}
},
/**
Returns the closest layer to latlng among a list of layers.
@tutorial closest
@param {L.Map} map Leaflet map to be used for this method
@param {Array<L.ILayer>} layers Set of layers
@param {L.LatLng} latlng - The position to search
@returns {object} ``{layer, latlng, distance}`` or ``null`` if list is empty;
*/
closestLayer: function (map, layers, latlng) {
var mindist = Infinity,
result = null,
ll = null,
distance = Infinity;
for (var i = 0, n = layers.length; i < n; i++) {
var layer = layers[i];
if (layer instanceof L.LayerGroup) {
// recursive
var subResult = L.GeometryUtil.closestLayer(map, layer.getLayers(), latlng);
if (subResult.distance < mindist) {
mindist = subResult.distance;
result = subResult;
}
} else {
if (layer instanceof L.Circle){
ll = this.closestOnCircle(layer, latlng);
distance = L.GeometryUtil.distance(map, latlng, ll);
} else
// Single dimension, snap on points, else snap on closest
if (typeof layer.getLatLng == 'function') {
ll = layer.getLatLng();
distance = L.GeometryUtil.distance(map, latlng, ll);
}
else {
ll = L.GeometryUtil.closest(map, layer, latlng);
if (ll) distance = ll.distance; // Can return null if layer has no points.
}
if (distance < mindist) {
mindist = distance;
result = {layer: layer, latlng: ll, distance: distance};
}
}
}
return result;
},
/**
Returns the n closest layers to latlng among a list of input layers.
@param {L.Map} map - Leaflet map to be used for this method
@param {Array<L.ILayer>} layers - Set of layers
@param {L.LatLng} latlng - The position to search
@param {?Number} [n=layers.length] - the expected number of output layers.
@returns {Array<object>} an array of objects ``{layer, latlng, distance}`` or ``null`` if the input is invalid (empty list or negative n)
*/
nClosestLayers: function (map, layers, latlng, n) {
n = typeof n === 'number' ? n : layers.length;
if (n < 1 || layers.length < 1) {
return null;
}
var results = [];
var distance, ll;
for (var i = 0, m = layers.length; i < m; i++) {
var layer = layers[i];
if (layer instanceof L.LayerGroup) {
// recursive
var subResult = L.GeometryUtil.closestLayer(map, layer.getLayers(), latlng);
results.push(subResult);
} else {
if (layer instanceof L.Circle){
ll = this.closestOnCircle(layer, latlng);
distance = L.GeometryUtil.distance(map, latlng, ll);
} else
// Single dimension, snap on points, else snap on closest
if (typeof layer.getLatLng == 'function') {
ll = layer.getLatLng();
distance = L.GeometryUtil.distance(map, latlng, ll);
}
else {
ll = L.GeometryUtil.closest(map, layer, latlng);
if (ll) distance = ll.distance; // Can return null if layer has no points.
}
results.push({layer: layer, latlng: ll, distance: distance});
}
}
results.sort(function(a, b) {
return a.distance - b.distance;
});
if (results.length > n) {
return results.slice(0, n);
} else {
return results;
}
},
/**
* Returns all layers within a radius of the given position, in an ascending order of distance.
@param {L.Map} map Leaflet map to be used for this method
@param {Array<ILayer>} layers - A list of layers.
@param {L.LatLng} latlng - The position to search
@param {?Number} [radius=Infinity] - Search radius in pixels
@return {object[]} an array of objects including layer within the radius, closest latlng, and distance
*/
layersWithin: function(map, layers, latlng, radius) {
radius = typeof radius == 'number' ? radius : Infinity;
var results = [];
var ll = null;
var distance = 0;
for (var i = 0, n = layers.length; i < n; i++) {
var layer = layers[i];
if (typeof layer.getLatLng == 'function') {
ll = layer.getLatLng();
distance = L.GeometryUtil.distance(map, latlng, ll);
}
else {
ll = L.GeometryUtil.closest(map, layer, latlng);
if (ll) distance = ll.distance; // Can return null if layer has no points.
}
if (ll && distance < radius) {
results.push({layer: layer, latlng: ll, distance: distance});
}
}
var sortedResults = results.sort(function(a, b) {
return a.distance - b.distance;
});
return sortedResults;
},
/**
Returns the closest position from specified {LatLng} among specified layers,
with a maximum tolerance in pixels, providing snapping behaviour.
@tutorial closest
@param {L.Map} map Leaflet map to be used for this method
@param {Array<ILayer>} layers - A list of layers to snap on.
@param {L.LatLng} latlng - The position to snap
@param {?Number} [tolerance=Infinity] - Maximum number of pixels.
@param {?boolean} [withVertices=true] - Snap to layers vertices or segment points (not only vertex)
@returns {object} with snapped {LatLng} and snapped {Layer} or null if tolerance exceeded.
*/
closestLayerSnap: function (map, layers, latlng, tolerance, withVertices) {
tolerance = typeof tolerance == 'number' ? tolerance : Infinity;
withVertices = typeof withVertices == 'boolean' ? withVertices : true;
var result = L.GeometryUtil.closestLayer(map, layers, latlng);
if (!result || result.distance > tolerance)
return null;
// If snapped layer is linear, try to snap on vertices (extremities and middle points)
if (withVertices && typeof result.layer.getLatLngs == 'function') {
var closest = L.GeometryUtil.closest(map, result.layer, result.latlng, true);
if (closest.distance < tolerance) {
result.latlng = closest;
result.distance = L.GeometryUtil.distance(map, closest, latlng);
}
}
return result;
},
/**
Returns the Point located on a segment at the specified ratio of the segment length.
@param {L.Point} pA coordinates of point A
@param {L.Point} pB coordinates of point B
@param {Number} the length ratio, expressed as a decimal between 0 and 1, inclusive.
@returns {L.Point} the interpolated point.
*/
interpolateOnPointSegment: function (pA, pB, ratio) {
return L.point(
(pA.x * (1 - ratio)) + (ratio * pB.x),
(pA.y * (1 - ratio)) + (ratio * pB.y)
);
},
/**
Returns the coordinate of the point located on a line at the specified ratio of the line length.
@param {L.Map} map Leaflet map to be used for this method
@param {Array<L.LatLng>|L.PolyLine} latlngs Set of geographical points
@param {Number} ratio the length ratio, expressed as a decimal between 0 and 1, inclusive
@returns {Object} an object with latLng ({LatLng}) and predecessor ({Number}), the index of the preceding vertex in the Polyline
(-1 if the interpolated point is the first vertex)
*/
interpolateOnLine: function (map, latLngs, ratio) {
latLngs = (latLngs instanceof L.Polyline) ? latLngs.getLatLngs() : latLngs;
var n = latLngs.length;
if (n < 2) {
return null;
}
// ensure the ratio is between 0 and 1;
ratio = Math.max(Math.min(ratio, 1), 0);
if (ratio === 0) {
return {
latLng: latLngs[0] instanceof L.LatLng ? latLngs[0] : L.latLng(latLngs[0]),
predecessor: -1
};
}
if (ratio == 1) {
return {
latLng: latLngs[latLngs.length -1] instanceof L.LatLng ? latLngs[latLngs.length -1] : L.latLng(latLngs[latLngs.length -1]),
predecessor: latLngs.length - 2
};
}
// project the LatLngs as Points,
// and compute total planar length of the line at max precision
var maxzoom = map.getMaxZoom();
if (maxzoom === Infinity)
maxzoom = map.getZoom();
var pts = [];
var lineLength = 0;
for(var i = 0; i < n; i++) {
pts[i] = map.project(latLngs[i], maxzoom);
if(i > 0)
lineLength += pts[i-1].distanceTo(pts[i]);
}
var ratioDist = lineLength * ratio;
// follow the line segments [ab], adding lengths,
// until we find the segment where the points should lie on
var cumulativeDistanceToA = 0, cumulativeDistanceToB = 0;
for (var i = 0; cumulativeDistanceToB < ratioDist; i++) {
var pointA = pts[i], pointB = pts[i+1];
cumulativeDistanceToA = cumulativeDistanceToB;
cumulativeDistanceToB += pointA.distanceTo(pointB);
}
if (pointA == undefined && pointB == undefined) { // Happens when line has no length
var pointA = pts[0], pointB = pts[1], i = 1;
}
// compute the ratio relative to the segment [ab]
var segmentRatio = ((cumulativeDistanceToB - cumulativeDistanceToA) !== 0) ? ((ratioDist - cumulativeDistanceToA) / (cumulativeDistanceToB - cumulativeDistanceToA)) : 0;
var interpolatedPoint = L.GeometryUtil.interpolateOnPointSegment(pointA, pointB, segmentRatio);
return {
latLng: map.unproject(interpolatedPoint, maxzoom),
predecessor: i-1
};
},
/**
Returns a float between 0 and 1 representing the location of the
closest point on polyline to the given latlng, as a fraction of total line length.
(opposite of L.GeometryUtil.interpolateOnLine())
@param {L.Map} map Leaflet map to be used for this method
@param {L.PolyLine} polyline Polyline on which the latlng will be search
@param {L.LatLng} latlng The position to search
@returns {Number} Float between 0 and 1
*/
locateOnLine: function (map, polyline, latlng) {
var latlngs = polyline.getLatLngs();
if (latlng.equals(latlngs[0]))
return 0.0;
if (latlng.equals(latlngs[latlngs.length-1]))
return 1.0;
var point = L.GeometryUtil.closest(map, polyline, latlng, false),
lengths = L.GeometryUtil.accumulatedLengths(latlngs),
total_length = lengths[lengths.length-1],
portion = 0,
found = false;
for (var i=0, n = latlngs.length-1; i < n; i++) {
var l1 = latlngs[i],
l2 = latlngs[i+1];
portion = lengths[i];
if (L.GeometryUtil.belongsSegment(point, l1, l2, 0.001)) {
portion += l1.distanceTo(point);
found = true;
break;
}
}
if (!found) {
throw "Could not interpolate " + latlng.toString() + " within " + polyline.toString();
}
return portion / total_length;
},
/**
Returns a clone with reversed coordinates.
@param {L.PolyLine} polyline polyline to reverse
@returns {L.PolyLine} polyline reversed
*/
reverse: function (polyline) {
return L.polyline(polyline.getLatLngs().slice(0).reverse());
},
/**
Returns a sub-part of the polyline, from start to end.
If start is superior to end, returns extraction from inverted line.
@param {L.Map} map Leaflet map to be used for this method
@param {L.PolyLine} polyline Polyline on which will be extracted the sub-part
@param {Number} start ratio, expressed as a decimal between 0 and 1, inclusive
@param {Number} end ratio, expressed as a decimal between 0 and 1, inclusive
@returns {Array<L.LatLng>} new polyline
*/
extract: function (map, polyline, start, end) {
if (start > end) {
return L.GeometryUtil.extract(map, L.GeometryUtil.reverse(polyline), 1.0-start, 1.0-end);
}
// Bound start and end to [0-1]
start = Math.max(Math.min(start, 1), 0);
end = Math.max(Math.min(end, 1), 0);
var latlngs = polyline.getLatLngs(),
startpoint = L.GeometryUtil.interpolateOnLine(map, polyline, start),
endpoint = L.GeometryUtil.interpolateOnLine(map, polyline, end);
// Return single point if start == end
if (start == end) {
var point = L.GeometryUtil.interpolateOnLine(map, polyline, end);
return [point.latLng];
}
// Array.slice() works indexes at 0
if (startpoint.predecessor == -1)
startpoint.predecessor = 0;
if (endpoint.predecessor == -1)
endpoint.predecessor = 0;
var result = latlngs.slice(startpoint.predecessor+1, endpoint.predecessor+1);
result.unshift(startpoint.latLng);
result.push(endpoint.latLng);
return result;
},
/**
Returns true if first polyline ends where other second starts.
@param {L.PolyLine} polyline First polyline
@param {L.PolyLine} other Second polyline
@returns {bool}
*/
isBefore: function (polyline, other) {
if (!other) return false;
var lla = polyline.getLatLngs(),
llb = other.getLatLngs();
return (lla[lla.length-1]).equals(llb[0]);
},
/**
Returns true if first polyline starts where second ends.
@param {L.PolyLine} polyline First polyline
@param {L.PolyLine} other Second polyline
@returns {bool}
*/
isAfter: function (polyline, other) {
if (!other) return false;
var lla = polyline.getLatLngs(),
llb = other.getLatLngs();
return (lla[0]).equals(llb[llb.length-1]);
},
/**
Returns true if first polyline starts where second ends or start.
@param {L.PolyLine} polyline First polyline
@param {L.PolyLine} other Second polyline
@returns {bool}
*/
startsAtExtremity: function (polyline, other) {
if (!other) return false;
var lla = polyline.getLatLngs(),
llb = other.getLatLngs(),
start = lla[0];
return start.equals(llb[0]) || start.equals(llb[llb.length-1]);
},
/**
Returns horizontal angle in degres between two points.
@param {L.Point} a Coordinates of point A
@param {L.Point} b Coordinates of point B
@returns {Number} horizontal angle
*/
computeAngle: function(a, b) {
return (Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI);
},
/**
Returns slope (Ax+B) between two points.
@param {L.Point} a Coordinates of point A
@param {L.Point} b Coordinates of point B
@returns {Object} with ``a`` and ``b`` properties.
*/
computeSlope: function(a, b) {
var s = (b.y - a.y) / (b.x - a.x),
o = a.y - (s * a.x);
return {'a': s, 'b': o};
},
/**
Returns LatLng of rotated point around specified LatLng center.
@param {L.LatLng} latlngPoint: point to rotate
@param {double} angleDeg: angle to rotate in degrees
@param {L.LatLng} latlngCenter: center of rotation
@returns {L.LatLng} rotated point
*/
rotatePoint: function(map, latlngPoint, angleDeg, latlngCenter) {
var maxzoom = map.getMaxZoom();
if (maxzoom === Infinity)
maxzoom = map.getZoom();
var angleRad = angleDeg*Math.PI/180,
pPoint = map.project(latlngPoint, maxzoom),
pCenter = map.project(latlngCenter, maxzoom),
x2 = Math.cos(angleRad)*(pPoint.x-pCenter.x) - Math.sin(angleRad)*(pPoint.y-pCenter.y) + pCenter.x,
y2 = Math.sin(angleRad)*(pPoint.x-pCenter.x) + Math.cos(angleRad)*(pPoint.y-pCenter.y) + pCenter.y;
return map.unproject(new L.Point(x2,y2), maxzoom);
},
/**
Returns the bearing in degrees clockwise from north (0 degrees)
from the first L.LatLng to the second, at the first LatLng
@param {L.LatLng} latlng1: origin point of the bearing
@param {L.LatLng} latlng2: destination point of the bearing
@returns {float} degrees clockwise from north.
*/
bearing: function(latlng1, latlng2) {
var rad = Math.PI / 180,
lat1 = latlng1.lat * rad,
lat2 = latlng2.lat * rad,
lon1 = latlng1.lng * rad,
lon2 = latlng2.lng * rad,
y = Math.sin(lon2 - lon1) * Math.cos(lat2),
x = Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1);
var bearing = ((Math.atan2(y, x) * 180 / Math.PI) + 360) % 360;
return bearing >= 180 ? bearing-360 : bearing;
},
/**
Returns the point that is a distance and heading away from
the given origin point.
@param {L.LatLng} latlng: origin point
@param {float} heading: heading in degrees, clockwise from 0 degrees north.
@param {float} distance: distance in meters
@returns {L.latLng} the destination point.
Many thanks to Chris Veness at http://www.movable-type.co.uk/scripts/latlong.html
for a great reference and examples.
*/
destination: function(latlng, heading, distance) {
heading = (heading + 360) % 360;
var rad = Math.PI / 180,
radInv = 180 / Math.PI,
R = L.CRS.Earth.R, // approximation of Earth's radius
lon1 = latlng.lng * rad,
lat1 = latlng.lat * rad,
rheading = heading * rad,
sinLat1 = Math.sin(lat1),
cosLat1 = Math.cos(lat1),
cosDistR = Math.cos(distance / R),
sinDistR = Math.sin(distance / R),
lat2 = Math.asin(sinLat1 * cosDistR + cosLat1 *
sinDistR * Math.cos(rheading)),
lon2 = lon1 + Math.atan2(Math.sin(rheading) * sinDistR *
cosLat1, cosDistR - sinLat1 * Math.sin(lat2));
lon2 = lon2 * radInv;
lon2 = lon2 > 180 ? lon2 - 360 : lon2 < -180 ? lon2 + 360 : lon2;
return L.latLng([lat2 * radInv, lon2]);
},
/**
Returns the the angle of the given segment and the Equator in degrees,
clockwise from 0 degrees north.
@param {L.Map} map: Leaflet map to be used for this method
@param {L.LatLng} latlngA: geographical point A of the segment
@param {L.LatLng} latlngB: geographical point B of the segment
@returns {Float} the angle in degrees.
*/
angle: function(map, latlngA, latlngB) {
var pointA = map.latLngToContainerPoint(latlngA),
pointB = map.latLngToContainerPoint(latlngB),
angleDeg = Math.atan2(pointB.y - pointA.y, pointB.x - pointA.x) * 180 / Math.PI + 90;
angleDeg += angleDeg < 0 ? 360 : 0;
return angleDeg;
},
/**
Returns a point snaps on the segment and heading away from the given origin point a distance.
@param {L.Map} map: Leaflet map to be used for this method
@param {L.LatLng} latlngA: geographical point A of the segment
@param {L.LatLng} latlngB: geographical point B of the segment
@param {float} distance: distance in meters
@returns {L.latLng} the destination point.
*/
destinationOnSegment: function(map, latlngA, latlngB, distance) {
var angleDeg = L.GeometryUtil.angle(map, latlngA, latlngB),
latlng = L.GeometryUtil.destination(latlngA, angleDeg, distance);
return L.GeometryUtil.closestOnSegment(map, latlng, latlngA, latlngB);
},
});
return L.GeometryUtil;
}));

View File

@ -0,0 +1,227 @@
(function (factory, window) {
if (typeof define === 'function' && define.amd) {
define(['leaflet'], factory);
} else if (typeof exports === 'object') {
module.exports = factory(require('leaflet'));
}
if (typeof window !== 'undefined' && window.L) {
window.L.PolylineOffset = factory(L);
}
}(function (L) {
function forEachPair(list, callback) {
if (!list || list.length < 1) { return; }
for (var i = 1, l = list.length; i < l; i++) {
callback(list[i-1], list[i]);
}
}
/**
Find the coefficients (a,b) of a line of equation y = a.x + b,
or the constant x for vertical lines
Return null if there's no equation possible
*/
function lineEquation(pt1, pt2) {
if (pt1.x === pt2.x) {
return pt1.y === pt2.y ? null : { x: pt1.x };
}
var a = (pt2.y - pt1.y) / (pt2.x - pt1.x);
return {
a: a,
b: pt1.y - a * pt1.x,
};
}
/**
Return the intersection point of two lines defined by two points each
Return null when there's no unique intersection
*/
function intersection(l1a, l1b, l2a, l2b) {
var line1 = lineEquation(l1a, l1b);
var line2 = lineEquation(l2a, l2b);
if (line1 === null || line2 === null) {
return null;
}
if (line1.hasOwnProperty('x')) {
return line2.hasOwnProperty('x')
? null
: {
x: line1.x,
y: line2.a * line1.x + line2.b,
};
}
if (line2.hasOwnProperty('x')) {
return {
x: line2.x,
y: line1.a * line2.x + line1.b,
};
}
if (line1.a === line2.a) {
return null;
}
var x = (line2.b - line1.b) / (line1.a - line2.a);
return {
x: x,
y: line1.a * x + line1.b,
};
}
function translatePoint(pt, dist, heading) {
return {
x: pt.x + dist * Math.cos(heading),
y: pt.y + dist * Math.sin(heading),
};
}
var PolylineOffset = {
offsetPointLine: function(points, distance) {
var offsetSegments = [];
forEachPair(points, L.bind(function(a, b) {
if (a.x === b.x && a.y === b.y) { return; }
// angles in (-PI, PI]
var segmentAngle = Math.atan2(a.y - b.y, a.x - b.x);
var offsetAngle = segmentAngle - Math.PI/2;
offsetSegments.push({
offsetAngle: offsetAngle,
original: [a, b],
offset: [
translatePoint(a, distance, offsetAngle),
translatePoint(b, distance, offsetAngle)
]
});
}, this));
return offsetSegments;
},
offsetPoints: function(pts, options) {
var offsetSegments = this.offsetPointLine(L.LineUtil.simplify(pts, options.smoothFactor), options.offset);
return this.joinLineSegments(offsetSegments, options.offset);
},
/**
Join 2 line segments defined by 2 points each with a circular arc
*/
joinSegments: function(s1, s2, offset) {
// TODO: different join styles
return this.circularArc(s1, s2, offset)
.filter(function(x) { return x; })
},
joinLineSegments: function(segments, offset) {
var joinedPoints = [];
var first = segments[0];
var last = segments[segments.length - 1];
if (first && last) {
joinedPoints.push(first.offset[0]);
forEachPair(segments, L.bind(function(s1, s2) {
joinedPoints = joinedPoints.concat(this.joinSegments(s1, s2, offset));
}, this));
joinedPoints.push(last.offset[1]);
}
return joinedPoints;
},
segmentAsVector: function(s) {
return {
x: s[1].x - s[0].x,
y: s[1].y - s[0].y,
};
},
getSignedAngle: function(s1, s2) {
const a = this.segmentAsVector(s1);
const b = this.segmentAsVector(s2);
return Math.atan2(a.x * b.y - a.y * b.x, a.x * b.x + a.y * b.y);
},
/**
Interpolates points between two offset segments in a circular form
*/
circularArc: function(s1, s2, distance) {
// if the segments are the same angle,
// there should be a single join point
if (s1.offsetAngle === s2.offsetAngle) {
return [s1.offset[1]];
}
const signedAngle = this.getSignedAngle(s1.offset, s2.offset);
// for inner angles, just find the offset segments intersection
if ((signedAngle * distance > 0) &&
(signedAngle * this.getSignedAngle(s1.offset, [s1.offset[0], s2.offset[1]]) > 0)) {
return [intersection(s1.offset[0], s1.offset[1], s2.offset[0], s2.offset[1])];
}
// draws a circular arc with R = offset distance, C = original meeting point
var points = [];
var center = s1.original[1];
// ensure angles go in the anti-clockwise direction
var rightOffset = distance > 0;
var startAngle = rightOffset ? s2.offsetAngle : s1.offsetAngle;
var endAngle = rightOffset ? s1.offsetAngle : s2.offsetAngle;
// and that the end angle is bigger than the start angle
if (endAngle < startAngle) {
endAngle += Math.PI * 2;
}
var step = Math.PI / 8;
for (var alpha = startAngle; alpha < endAngle; alpha += step) {
points.push(translatePoint(center, distance, alpha));
}
points.push(translatePoint(center, distance, endAngle));
return rightOffset ? points.reverse() : points;
}
}
// Modify the L.Polyline class by overwriting the projection function
L.Polyline.include({
_projectLatlngs: function (latlngs, result, projectedBounds) {
var isFlat = latlngs.length > 0 && latlngs[0] instanceof L.LatLng;
if (isFlat) {
var ring = latlngs.map(L.bind(function(ll) {
var point = this._map.latLngToLayerPoint(ll);
if (projectedBounds) {
projectedBounds.extend(point);
}
return point;
}, this));
// Offset management hack ---
if (this.options.offset) {
ring = L.PolylineOffset.offsetPoints(ring, this.options);
}
// Offset management hack END ---
result.push(ring.map(function (xy) {
return L.point(xy.x, xy.y);
}));
} else {
latlngs.forEach(L.bind(function(ll) {
this._projectLatlngs(ll, result, projectedBounds);
}, this));
}
}
});
L.Polyline.include({
setOffset: function(offset) {
this.options.offset = offset;
this.redraw();
return this;
}
});
return PolylineOffset;
}, window));

View File

@ -0,0 +1,669 @@
/* globals L:true */
L.Snap = {};
L.Snap.isDifferentLayer = function (marker, layer) {
var i;
var n;
var markerId = L.stamp(marker);
if (layer.hasOwnProperty('_snapIgnore')) {
return false;
}
if (layer.hasOwnProperty('_topOwner') && marker.hasOwnProperty('_topOwner')) {
return layer._topOwner !== marker._topOwner;
}
if (layer instanceof L.Marker) {
return markerId !== L.stamp(layer);
}
if (layer.editing && layer.editing._enabled) {
if (layer.editing._verticesHandlers) {
var points = layer.editing._verticesHandlers[0]._markerGroup.getLayers();
for(i = 0, n = points.length; i < n; i++) {
if (L.stamp(points[i]) == markerId) {
return false;
}
}
}
else if (layer.editing._resizeMarkers) {
for(i = 0; i < layer.editing._resizeMarkers.length; i++) {
var resizeMarker = layer.editing._resizeMarkers[i];
if (L.stamp(resizeMarker) == markerId) {
return false;
}
}
if (layer.editing._moveMarker) {
return markerId !== L.stamp(layer.editing._moveMarker);
}
return true;
}
}
return true;
};
L.Snap.processGuide = function (latlng, marker, guide, snaplist, buffer) {
// Guide is a layer group and has no L.LayerIndexMixin (from Leaflet.LayerIndex)
if ((guide._layers !== undefined) && (typeof guide.searchBuffer !== 'function')) {
for (var id in guide._layers) {
if (guide._layers.hasOwnProperty(id)) {
L.Snap.processGuide(latlng, marker, guide._layers[id], snaplist, buffer);
}
}
}
// Search snaplist around mouse
else if (typeof guide.searchBuffer === 'function') {
var nearlayers = guide.searchBuffer(latlng, buffer);
snaplist = snaplist.concat(nearlayers.filter(function(layer) {
return L.Snap.isDifferentLayer(layer);
}));
}
// Make sure the marker doesn't snap to itself or an associated polyline layer
else if (L.Snap.isDifferentLayer(marker, guide)) {
snaplist.push(guide);
}
};
L.Snap.findClosestLayerSnap = function (map, layers, latlng, tolerance, withVertices) {
var closest = L.GeometryUtil.nClosestLayers(map, layers, latlng, 6);
// code to correct prefer snap to shapes (and their vertices, if withVertices is true) to gridlines and guidelines, and then guidelines to gridlines
var withinTolerance = [];
var pointsWithinTolerance = [];
var shapesWithinTolerance = [];
var guidesWithinTolerance = [];
for (var c=0; c<closest.length; c++) {
var layerInfo = closest[c];
if (layerInfo.distance < tolerance) {
withinTolerance.push(layerInfo);
if (layerInfo.layer.hasOwnProperty('_latlng')) {
pointsWithinTolerance.push(layerInfo);
}
else if ((! layerInfo.layer.hasOwnProperty('_gridlineGroup')) && (! layerInfo.layer.hasOwnProperty('_guidelineGroup'))) {
shapesWithinTolerance.push(layerInfo);
}
else if (layerInfo.layer.hasOwnProperty('_guidelineGroup')) {
guidesWithinTolerance.push(layerInfo);
}
}
}
if (withinTolerance.length === 0) {
return null;
}
var intInfo;
var returnLayer = withinTolerance[0].layer;
var returnLatLng = withinTolerance[0].latlng;
if (pointsWithinTolerance.length > 0) {
var pointInfo = pointsWithinTolerance[0];
returnLayer = pointInfo.layer;
returnLatLng = pointInfo.latlng;
}
else if (shapesWithinTolerance.length > 0) {
var shapeInfo = shapesWithinTolerance[0];
returnLayer = shapeInfo.layer;
returnLatLng = shapeInfo.latlng;
// this is code from L.GeometryUtil.closestSnap that will find
// the closest vertex of this layer to the point
if (withVertices && (typeof shapeInfo.layer.getLatLngs == 'function')) {
var vertexLatLng = L.GeometryUtil.closest(map, shapeInfo.layer, shapeInfo.latlng, true);
if (vertexLatLng) {
var d = L.GeometryUtil.distance(map, latlng, vertexLatLng);
if (d < tolerance) {
returnLatLng = new L.LatLng(vertexLatLng.lat, vertexLatLng.lng);
}
}
}
}
else if (guidesWithinTolerance.length > 0) {
var guideInfo = guidesWithinTolerance[0];
var guideType = guideInfo.layer._guidelineGroup;
for (var i=0; i<withinTolerance.length; i++) {
if (withinTolerance[i].layer._gridlineGroup != guideType) {
intInfo = L.Snap.findGuideIntersection('guide', map, latlng, [guideInfo, withinTolerance[i]]);
if (intInfo.distance < tolerance) {
returnLatLng = intInfo.intersection;
break;
}
}
}
}
else {
if (withinTolerance.length == 2) {
intInfo = L.Snap.findGuideIntersection('grid', map, latlng, withinTolerance);
if (intInfo.distance < tolerance) {
returnLatLng = intInfo.intersection;
}
}
}
return {
'layer' : returnLayer,
'latlng': returnLatLng
};
};
// Compatibility method to normalize Poly* objects
// between 0.7.x and 1.0+
// pulled from code from L.Edit.Poly in Leaflet.Draw
L.Snap.defaultShape = function (latlngs) {
if (!L.Polyline._flat) { return latlngs; }
return L.Polyline._flat(latlngs) ? latlngs : latlngs[0];
};
// try to prefer the corner of guidelines, or the the intersection of gridlines, if we're within the tolerance of two
L.Snap.findGuideIntersection = function (gType, map, latlng, guides) {
var nsi = (guides[0].layer['_' + gType + 'lineGroup'] == 'NS') ? 1 : 0;
var wei = (guides[0].layer['_' + gType + 'lineGroup'] == 'NS') ? 0 : 1;
var ns = L.Snap.defaultShape(guides[nsi].layer._latlngs)[0];
var we = L.Snap.defaultShape(guides[wei].layer._latlngs)[0];
var intersection = new L.LatLng(ns.lat, we.lng);
var distance = L.GeometryUtil.distance(map, intersection, latlng);
return {
'intersection': intersection,
'distance': distance
};
};
L.Snap.updateSnap = function (marker, layer, latlng) {
if (! marker.hasOwnProperty('_latlng')) {
return;
}
if (layer && latlng) {
// don't call setLatLng so that we don't fire an unnecessary 'move' event
marker._latlng = L.latLng(latlng);
marker.update();
if (marker.snap != layer) {
marker.snap = layer;
if (marker._icon) {
L.DomUtil.addClass(marker._icon, 'marker-snapped');
}
marker.fire('snap', {layer:layer, latlng: latlng});
}
}
else {
if (marker.snap) {
if (marker._icon) {
L.DomUtil.removeClass(marker._icon, 'marker-snapped');
}
marker.fire('unsnap', {layer: marker.snap});
}
delete marker.snap;
}
};
L.Snap.snapMarker = function (e, guides, map, options, buffer) {
var marker = e.target;
var latlng = e.target._latlng || e.latlng;
if (! latlng) {
return;
}
var snaplist = [];
for (var i=0, n = guides.length; i < n; i++) {
var guide = guides[i];
// don't snap to vertices of a poly object for poly move
if (marker.hasOwnProperty('_owner') && (guide._leaflet_id == marker._owner)) {
continue;
}
L.Snap.processGuide(latlng, marker, guide, snaplist, buffer);
}
if (snaplist.length === 0) {
return;
}
var closest = L.Snap.findClosestLayerSnap(map, snaplist, latlng, options.snapDistance, options.snapVertices);
closest = closest || {layer: null, latlng: null};
L.Snap.updateSnap(marker, closest.layer, closest.latlng);
if (e.latlng && closest.latlng) {
e.latlng = closest.latlng;
}
return closest;
};
L.Handler.MarkerSnap = L.Handler.extend({
options: {
snapDistance: 15, // in pixels
snapVertices: true
},
initialize: function (map, marker, options) {
L.Handler.prototype.initialize.call(this, map);
this._markers = [];
this._guides = [];
if (arguments.length == 2) {
if (!(marker instanceof L.Class)) {
options = marker;
marker = null;
}
}
L.Util.setOptions(this, options || {});
if (marker) {
// new markers should be draggable !
if (!marker.dragging) marker.dragging = new L.Handler.MarkerDrag(marker);
marker.dragging.enable();
this.watchMarker(marker);
}
// Convert snap distance in pixels into buffer in degres, for searching around mouse
// It changes at each zoom change.
function computeBuffer() {
this._buffer = map.layerPointToLatLng(new L.Point(0,0)).lat -
map.layerPointToLatLng(new L.Point(this.options.snapDistance, 0)).lat;
}
map.on('zoomend', computeBuffer, this);
map.whenReady(computeBuffer, this);
computeBuffer.call(this);
},
enable: function () {
this.disable();
for (var i=0; i<this._markers.length; i++) {
this.watchMarker(this._markers[i]);
}
},
disable: function () {
for (var i=0; i<this._markers.length; i++) {
this.unwatchMarker(this._markers[i]);
}
},
watchMarker: function (marker) {
if (this._markers.indexOf(marker) == -1)
this._markers.push(marker);
marker.on('move', this._snapMarker, this);
this._map.on('touchmove', this._snapMarker, this);
},
unwatchMarker: function (marker) {
marker.off('move', this._snapMarker, this);
this._map.off('touchmove', this._snapMarker, this);
delete marker.snap;
},
addGuideLayer: function (layer) {
for (var i=0, n=this._guides.length; i<n; i++)
if (L.stamp(layer) == L.stamp(this._guides[i]))
return;
this._guides.push(layer);
},
_snapMarker: function(e) {
var closest = L.Snap.snapMarker(e, this._guides, this._map, this.options, this._buffer);
if (e.originalEvent && e.originalEvent.clientX && closest.layer && closest.latlng) {
var snapTouchPoint = this._map.project(closest.latlng, this._map.getZoom());
e.originalEvent.clientX = snapTouchPoint.x;
e.originalEvent.clientY = snapTouchPoint.y;
e.originalEvent.snapped = true;
}
}
});
L.Handler.PolylineSnap = L.Edit.Poly.extend({
initialize: function (map, poly, options) {
var that = this;
L.Edit.Poly.prototype.initialize.call(this, poly, options);
this._snapper = new L.Handler.MarkerSnap(map, options);
poly.on('remove', function() {
that.disable();
});
},
addGuideLayer: function (layer) {
this._snapper.addGuideLayer(layer);
},
_createMoveMarker: function (latlng, icon) {
var marker = L.Edit.Poly.prototype._createMoveMarker.call(this, latlng, icon);
this._poly.snapediting._snapper.watchMarker(marker);
return marker;
},
_initHandlers: function () {
this._verticesHandlers = [];
for (var i = 0; i < this.latlngs.length; i++) {
this._verticesHandlers.push(new L.Edit.PolyVerticesEditSnap(this._poly, this.latlngs[i], this.options));
}
}
});
L.Edit.PolyVerticesEditSnap = L.Edit.PolyVerticesEdit.extend({
_createMarker: function (latlng, index) {
var marker = L.Edit.PolyVerticesEdit.prototype._createMarker.call(this, latlng, index);
// Treat middle markers differently
var isMiddle = ((index === null) || (typeof(index) == 'undefined'));
if (isMiddle) {
// Snap middle markers, only once they were touched
marker.on('dragstart', function () {
this._poly.snapediting._snapper.watchMarker(marker);
}, this);
}
else {
this._poly.snapediting._snapper.watchMarker(marker);
}
return marker;
}
});
L.Handler.RectangleSnap = L.Edit.Rectangle.extend({
initialize: function (map, shape, options) {
L.Edit.Rectangle.prototype.initialize.call(this, shape, options);
this._snapper = new L.Handler.MarkerSnap(map, options);
},
_createMarker: function (latlng, icon) {
var marker = L.Edit.Rectangle.prototype._createMarker.call(this, latlng, icon);
this._shape.snapediting._snapper.watchMarker(marker);
return marker;
},
addGuideLayer: function (layer) {
this._snapper.addGuideLayer(layer);
},
});
L.Handler.CircleSnap = L.Edit.Circle.extend({
initialize: function (map, shape, options) {
L.Edit.Circle.prototype.initialize.call(this, shape, options);
this._snapper = new L.Handler.MarkerSnap(map, options);
},
_createMarker: function (latlng, icon) {
var marker = L.Edit.Circle.prototype._createMarker.call(this, latlng, icon);
this._shape.snapediting._snapper.watchMarker(marker);
return marker;
},
addGuideLayer: function (layer) {
this._snapper.addGuideLayer(layer);
},
});
L.EditToolbar.SnapEdit = L.EditToolbar.Edit.extend({
snapOptions: {
snapDistance: 15, // in pixels
snapVertices: true
},
initialize: function(map, options) {
L.EditToolbar.Edit.prototype.initialize.call(this, map, options);
if (options.snapOptions) {
L.Util.extend(this.snapOptions, options.snapOptions);
}
if (Array.isArray(this.snapOptions.guideLayers)) {
this._guideLayers = this.snapOptions.guideLayers;
} else if (options.guideLayers instanceof L.LayerGroup) {
this._guideLayers = this.snapOptions.guideLayers.getLayers();
} else {
this._guideLayers = [];
}
},
addGuideLayer: function(layer) {
var index = this._guideLayers.findIndex(function(guideLayer) {
return L.stamp(layer) == L.stamp(guideLayer);
});
if (index == -1) {
this._guideLayers.push(layer);
this._featureGroup.eachLayer(function(layer) {
if (layer.snapediting) {
layer.snapediting._guides.push(layer);
}
});
}
},
removeGuideLayer: function(layer) {
var index = this._guideLayers.findIndex(function(guideLayer) {
return L.stamp(layer) == L.stamp(guideLayer);
});
if (index !== -1) {
this._guideLayers.splice(index, 1);
this._featureGroup.eachLayer(function(layer) {
if (layer.snapediting) { layer.snapediting._guides.splice(index, 1); }
});
}
},
clearGuideLayers: function() {
this._guideLayers = [];
this._featureGroup.eachLayer(function(layer) {
if (layer.snapediting) { layer.snapediting._guides = []; }
});
},
// essentially, the idea here is that we're gonna find the currently instantiated L.Edit handler, figure out its type,
// get rid of it, and then replace it with a snapedit instead
_enableLayerEdit: function(e) {
L.EditToolbar.Edit.prototype._enableLayerEdit.call(this, e);
var layer = e.layer || e.target || e;
if (!layer.snapediting) {
if (layer.hasOwnProperty('_mRadius')) {
if (layer.editing) {
layer.editing._markerGroup.clearLayers();
delete layer.editing;
}
layer.editing = layer.snapediting = new L.Handler.CircleSnap(layer._map, layer, this.snapOptions);
}
else if (layer.getLatLng) {
layer.snapediting = new L.Handler.MarkerSnap(layer._map, layer, this.snapOptions);
}
else {
if (layer.editing) {
if (layer.editing.hasOwnProperty('_shape')) {
layer.editing._markerGroup.clearLayers();
if (layer.editing._shape instanceof L.Rectangle) {
delete layer.editing;
layer.editing = layer.snapediting = new L.Handler.RectangleSnap(layer._map, layer, this.snapOptions);
}
else if (layer.editing._shape instanceof L.FeatureGroup) {
delete layer.editing;
layer.editing = layer.snapediting = new L.Handler.FeatureGroupSnap(layer._map, layer, this.snapOptions);
}
else {
delete layer.editing;
layer.editing = layer.snapediting = new L.Handler.CircleSnap(layer._map, layer, this.snapOptions);
}
}
else {
layer.editing._markerGroup.clearLayers();
layer.editing._verticesHandlers[0]._markerGroup.clearLayers();
delete layer.editing;
layer.editing = layer.snapediting = new L.Handler.PolylineSnap(layer._map, layer, this.snapOptions);
}
}
else {
layer.editing = layer.snapediting = new L.Handler.PolylineSnap(layer._map, layer, this.snapOptions);
}
}
for (var i = 0, n = this._guideLayers.length; i < n; i++) {
layer.snapediting.addGuideLayer(this._guideLayers[i]);
}
}
layer.snapediting.enable();
}
});
L.EditToolbar.prototype.getEditHandler = function (map, featureGroup) {
return new L.EditToolbar.SnapEdit(map, {
snapOptions: this.options.snapOptions,
featureGroup: featureGroup,
selectedPathOptions: this.options.edit.selectedPathOptions,
poly: this.options.poly
});
};
L.Draw.Feature.SnapMixin = {
_snap_initialize: function () {
this.on('enabled', this._snap_on_enabled, this);
this.on('disabled', this._snap_on_disabled, this);
},
_snap_on_enabled: function () {
if (!this.options.guideLayers) {
return;
}
if (! this._mouseMarker) {
this._map.on('layeradd', this._snap_on_enabled, this);
return;
}
else {
this._map.off('layeradd', this._snap_on_enabled, this);
}
if (!this._snapper) {
this._snapper = new L.Handler.MarkerSnap(this._map);
if (this.options.snapDistance) {
this._snapper.options.snapDistance = this.options.snapDistance;
}
if (this.options.snapVertices) {
this._snapper.options.snapVertices = this.options.snapVertices;
}
}
for (var i=0, n=this.options.guideLayers.length; i<n; i++) {
this._snapper.addGuideLayer(this.options.guideLayers[i]);
}
var marker = this._mouseMarker;
this._snapper.watchMarker(marker);
// Show marker when (snap for user feedback)
var icon = marker.options.icon;
marker.on('snap', function (e) {
marker.setIcon(this.options.icon);
marker.setOpacity(1);
}, this);
marker.on('unsnap', function (e) {
marker.setIcon(icon);
marker.setOpacity(0);
}, this);
marker.on('click', this._snap_on_click, this);
this._map.on('mousedown', this._snap_on_click, this);
this._map.on('touchstart', this._snap_on_click, this);
},
_snap_on_click: function (e) {
if (this._errorShown) {
return;
}
// for touch
if (this._markers) {
var markerCount = this._markers.length;
var marker = this._markers[markerCount - 1];
if (marker && this._mouseMarker.snap) {
L.DomUtil.addClass(marker._icon, 'marker-snapped');
}
}
// for shapes
if (this._startLatLng) {
var closest = this._manuallyCorrectClick(this._startLatLng);
if (closest.latlng) {
this._mouseMarker.setLatLng(closest.latlng);
this._startLatLng = closest.latlng;
}
}
// for poly vertices
if (this._mouseDownOrigin) {
var z = this._map.getZoom();
var mdOrigin = this._map.unproject(this._mouseDownOrigin, z);
var closestMDO = this._manuallyCorrectClick(mdOrigin);
if (closestMDO.latlng) {
this._mouseMarker.setLatLng(closestMDO.latlng);
this._mouseDownOrigin = this._map.project(closestMDO.latlng, z);
}
if (e.originalEvent) {
var oeOrigin = this._map.unproject([e.originalEvent.clientX, e.originalEvent.clientY], z);
var closestOE = this._manuallyCorrectClick(oeOrigin);
if (closestOE.latlng) {
e.originalEvent = this._map.project(closestOE.latlng, z);
}
}
}
},
_manuallyCorrectClick: function (originalLatLng) {
var ex = {
'target': this._mouseMarker,
'latlng': originalLatLng
};
if (! this._mouseMarker) {
return {
'latlng': null
};
}
var buffer = 0;
if (this.hasOwnProperty('_snapper') && this._snapper.hasOwnProperty('_buffer')) {
buffer = this._snapper._buffer;
}
return L.Snap.snapMarker(ex, this.options.guideLayers || [], this._map, this.options, buffer);
},
_snap_on_disabled: function () {
delete this._snapper;
},
};
L.Draw.Feature.include(L.Draw.Feature.SnapMixin);
L.Draw.Feature.addInitHook('_snap_initialize');

5
Development/client/src/mermaid.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module 'mermaid' {
const mermaid: any;
export default mermaid;
}

View File

@ -151,6 +151,21 @@ span.align-enter {
text-align: center; text-align: center;
} }
// The edge buffer dialog renders inside .leaflet-container which sets font-size: 12px.
// Override to match the page body font size (0.875rem 14px).
.edge-buf-dialog.ui-dialog {
font-size: 0.875rem;
}
// Compact edge-side select buttons so all three fit on one line in the dialog
.edge-buf-side-btn .ui-button {
padding: 0.25em 0.5em;
font-size: 0.85em;
display: inline-flex;
align-items: center;
justify-content: center;
}
.color-box { .color-box {
background-color: white; background-color: white;
width: 14px; width: 14px;

View File

@ -361,11 +361,7 @@ async function generateExport(exportJobId) {
} catch (err) { } catch (err) {
exportJob.status = ExportJobStatus.ERROR; exportJob.status = ExportJobStatus.ERROR;
exportJob.errorMsg = err.message; exportJob.errorMsg = err.message;
try { await exportJob.save();
await exportJob.save();
} catch (saveErr) {
// ExportJob may have been deleted before we could update it ignore
}
console.error('[export] generation failed', err); console.error('[export] generation failed', err);
} }
} }

View File

@ -20,6 +20,7 @@ const CUSTOMER_FILTER_SCHEMA = {
email: 'text', email: 'text',
contact: 'text', contact: 'text',
createdAt: 'date-preset', createdAt: 'date-preset',
selfSignup: 'select',
}; };
async function getCustomers_get(req, res) { async function getCustomers_get(req, res) {

View File

@ -246,7 +246,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"format\": \"csv\",\n \"interval\": 1,\n \"units\": \"metric\"\n}", "raw": "{\n \"format\": \"csv\",\n \"interval\": 1,\n \"units\": \"metric\",\n \"fm\": false\n}",
"options": { "raw": { "language": "json" } } "options": { "raw": { "language": "json" } }
}, },
"url": { "url": {
@ -254,7 +254,43 @@
"host": ["{{baseUrl}}"], "host": ["{{baseUrl}}"],
"path": ["api", "v1", "jobs", "{{jobId}}", "export"] "path": ["api", "v1", "jobs", "{{jobId}}", "export"]
}, },
"description": "Triggers async export generation. Returns `exportId` immediately (status = `pending`).\n\nBody fields:\n- `format`: `csv` | `geojson`\n- `interval`: GPS thinning in seconds (omit for all points)\n- `units`: `metric` (default) | `us`" "description": "Triggers async export generation. Returns `exportId` immediately (status = `pending`).\n\nBody fields:\n- `format`: `csv` | `geojson`\n- `interval`: GPS thinning in seconds (omit for all points)\n- `units`: `metric` (default) | `us`\n- `fm`: `false` by default; set `true` only for FM/AgDisp customers"
}
},
{
"name": "1 — Trigger Export (CSV, metric, FM (Flight Master), enabled)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"const r = pm.response.json();",
"if (r && r.exportId) {",
" pm.collectionVariables.set('exportId', r.exportId);",
" console.log('exportId captured (FM enabled):', r.exportId);",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "X-API-Key", "value": "{{apiKey}}" }
],
"body": {
"mode": "raw",
"raw": "{\n \"format\": \"csv\",\n \"interval\": 1,\n \"units\": \"metric\",\n \"fm\": true\n}",
"options": { "raw": { "language": "json" } }
},
"url": {
"raw": "{{baseUrl}}/api/v1/jobs/{{jobId}}/export",
"host": ["{{baseUrl}}"],
"path": ["api", "v1", "jobs", "{{jobId}}", "export"]
},
"description": "Same as CSV metric export, but explicitly enables Flight Master / AgDisp fields with `fm: true` (`sprayHeight_m`, `driftX_m`, `driftY_m`, `depositX_m`, `depositY_m`, `radarAlt_m`, `laserAlt_m`)."
} }
}, },
{ {
@ -282,7 +318,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"format\": \"csv\",\n \"interval\": 1,\n \"units\": \"us\"\n}", "raw": "{\n \"format\": \"csv\",\n \"interval\": 1,\n \"units\": \"us\",\n \"fm\": false\n}",
"options": { "raw": { "language": "json" } } "options": { "raw": { "language": "json" } }
}, },
"url": { "url": {
@ -290,7 +326,7 @@
"host": ["{{baseUrl}}"], "host": ["{{baseUrl}}"],
"path": ["api", "v1", "jobs", "{{jobId}}", "export"] "path": ["api", "v1", "jobs", "{{jobId}}", "export"]
}, },
"description": "Same as CSV metric but with `units: 'us'`. Column headers will use US unit suffixes (e.g. `groundSpeed_mph`, `alt_ft`, `temp_f`, `appRateApplied_galAc`)." "description": "Same as CSV metric but with `units: 'us'`. Column headers will use US unit suffixes (e.g. `groundSpeed_mph`, `alt_ft`, `temp_f`, `appRateApplied_galAc`). `fm` remains opt-in and should stay `false` for non-FM customers."
} }
}, },
{ {
@ -318,7 +354,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"format\": \"geojson\"\n}", "raw": "{\n \"format\": \"geojson\",\n \"fm\": false\n}",
"options": { "raw": { "language": "json" } } "options": { "raw": { "language": "json" } }
}, },
"url": { "url": {

View File

@ -0,0 +1,131 @@
# MarkdownViewerComponent
Selector: `app-markdown-viewer`
Module: `AppSharedModule` (already exported — no extra import needed)
A generic, self-contained markdown renderer. Handles parsing, heading-based section splitting, Mermaid diagrams, inline video embeds, syntax-highlighted find-in-page, and emits a table-of-contents item list for the host to render wherever it likes.
---
## Inputs
| Input | Type | Default | Description |
|---|---|---|---|
| `src` | `string` | `undefined` | URL of a remote `.md` file to fetch and render. Mutually exclusive with `markdown`. |
| `markdown` | `string` | `undefined` | Raw markdown string to render inline. Takes precedence over `src` if both are set. |
| `showFindBar` | `boolean` | `false` | Show the find-in-page bar above the content. |
---
## Outputs
| Output | Payload | Description |
|---|---|---|
| `tocItemsChange` | `{ label: string; anchorId: string }[]` | Emitted after content loads. Each item corresponds to a top-level heading in the document. Use this to render a Table of Contents outside the component. |
---
## Public Methods
| Method | Signature | Description |
|---|---|---|
| `scrollToId` | `(anchorId: string) => void` | Scrolls the content area to the element with the given id. Use in conjunction with `tocItemsChange` to implement external TOC navigation. |
---
## Usage Examples
### Render a remote file
```html
<app-markdown-viewer src="/assets/docs/readme.md"></app-markdown-viewer>
```
### Render an inline string
```html
<app-markdown-viewer [markdown]="myMarkdownString"></app-markdown-viewer>
```
### With find-in-page bar
```html
<app-markdown-viewer [markdown]="content" [showFindBar]="true"></app-markdown-viewer>
```
### With an external Table of Contents
The component emits TOC items but does **not** render a TOC sidebar itself. The host component is responsible for displaying the list and wiring up scroll navigation.
**Template:**
```html
<!-- TOC rendered by the host -->
<ul>
<li *ngFor="let item of tocItems">
<a href="#" (click)="scrollToHeading($event, item.anchorId)">{{ item.label }}</a>
</li>
</ul>
<!-- Viewer -->
<app-markdown-viewer
#viewer
[markdown]="content"
(tocItemsChange)="tocItems = $event">
</app-markdown-viewer>
```
**Component:**
```typescript
import { ViewChild } from '@angular/core';
import { MarkdownViewerComponent } from '../shared/markdown-viewer/markdown-viewer.component';
export class MyComponent {
@ViewChild(MarkdownViewerComponent) viewer?: MarkdownViewerComponent;
tocItems: { label: string; anchorId: string }[] = [];
content = '# Hello\n\nSome text.\n\n## Section Two\n\nMore text.';
scrollToHeading(event: MouseEvent, anchorId: string): void {
event.preventDefault();
this.viewer?.scrollToId(anchorId);
}
}
```
---
## Content Features
### Mermaid diagrams
Fenced code blocks with language `mermaid` are automatically rendered as SVG diagrams:
````markdown
```mermaid
graph TD
A --> B
```
````
### Inline video embeds
Use the custom `!video[title](url)` syntax to embed video files (`.mp4`, `.webm`, `.ogg`) or iframes (YouTube, Vimeo, etc.):
```markdown
!video[Demo walkthrough](https://example.com/demo.mp4)
!video[YouTube video](https://www.youtube.com/embed/abc123)
```
### Tables
Standard markdown tables are styled with borders and alternating row colours automatically.
---
## Notes
- Content is split into sections at every heading. The text before the first heading becomes an "intro" block.
- `src` and `markdown` are mutually exclusive. If both are provided, `markdown` wins.
- `scrollToId` is a no-op if the component has not yet rendered or the anchor does not exist in the current content.
- The component is part of `AppSharedModule` and does not need to be imported separately in feature modules that already import `AppSharedModule`.

View File

@ -104,8 +104,8 @@ const schema = new Schema({
bufs: [{ bufs: [{
properties: { type: Schema.Types.Mixed }, properties: { type: Schema.Types.Mixed },
geometry: { geometry: {
type: { type: String, required: true }, type: { type: String, required: true },
coordinates: { type: [[Number]], required: true } coordinates: { type: Schema.Types.Mixed, required: true }
} }
}], }],
waypoints: [{ waypoints: [{
@ -180,7 +180,6 @@ schema.plugin(AutoIncrement, { inc_field: '_id' });
schema.index({ 'sprayAreas.geometry': '2dsphere' }); schema.index({ 'sprayAreas.geometry': '2dsphere' });
schema.index({ 'excludedAreas.geometry': '2dsphere' }); schema.index({ 'excludedAreas.geometry': '2dsphere' });
*/ */
schema.index({ 'bufs.geometry': '2dsphere' });
schema.index({ 'waypoints.geometry': '2dsphere' }); schema.index({ 'waypoints.geometry': '2dsphere' });
schema.index({ 'places.geometry': '2dsphere' }); schema.index({ 'places.geometry': '2dsphere' });