all Data Export API changes until may 8 2026
This commit is contained in:
parent
ea46cbeb02
commit
ad7db99f07
@ -57,6 +57,11 @@
|
||||
"glob": "CHANGELOG.md",
|
||||
"input": "docs",
|
||||
"output": "/assets/docs"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "docs/releases",
|
||||
"output": "/assets/docs/releases"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
|
||||
@ -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
|
||||
1454
Development/client/package-lock.json
generated
1454
Development/client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,12 +4,19 @@
|
||||
"license": "COMMERCIAL",
|
||||
"angular-cli": {},
|
||||
"scripts": {
|
||||
"generate-release-manifest": "node scripts/generate-release-manifest.js",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"prebuild": "npm run generate-release-manifest",
|
||||
"build": "ng build",
|
||||
"prebuild-prep": "npm run generate-release-manifest",
|
||||
"build-prep": "ng build --aot --localize=false",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
@ -22,7 +29,9 @@
|
||||
"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",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"private": true,
|
||||
@ -58,10 +67,13 @@
|
||||
"geodesy": "^1.1.3",
|
||||
"intl": "^1.2.5",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-river": "^1.0.1",
|
||||
"marked": "^1.2.9",
|
||||
"mermaid": "^8.14.0",
|
||||
"ngrx-store-localstorage": "^9.0.0",
|
||||
"ngx-captcha": "^8.0.1",
|
||||
"ngx-markdown": "^9.1.1",
|
||||
"polygon-clipping": "^0.15.7",
|
||||
"primeng-lts": "^9.2.8",
|
||||
"quill": "^1.3.7",
|
||||
"rbush": "^3.0.1",
|
||||
|
||||
36
Development/client/scripts/generate-release-manifest.js
Normal file
36
Development/client/scripts/generate-release-manifest.js
Normal 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();
|
||||
@ -106,10 +106,15 @@ const routes: Routes = [
|
||||
runGuardsAndResolvers: 'always'
|
||||
},
|
||||
{
|
||||
path: 'changelog',
|
||||
loadChildren: () => import('./changelog/changelog.module').then(m => m.ChangelogModule),
|
||||
path: 'release-notes',
|
||||
loadChildren: () => import('./release-notes/release-notes.module').then(m => m.ReleaseNotesModule),
|
||||
runGuardsAndResolvers: 'always'
|
||||
},
|
||||
{
|
||||
path: 'changelog',
|
||||
redirectTo: 'release-notes',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -59,7 +59,7 @@ export class AppMenuComponent implements OnInit {
|
||||
{ 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;
|
||||
}
|
||||
@ -76,12 +76,7 @@ export class AppMenuComponent implements OnInit {
|
||||
{
|
||||
id: 'Help',
|
||||
label: $localize`:@@help:Help`, icon: 'help_outline',
|
||||
items: [{
|
||||
label: $localize`:@@trainingVideos:Training Videos`,
|
||||
icon: 'video_library',
|
||||
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
|
||||
target: '_blank'
|
||||
}]
|
||||
items: this.buildHelpMenuItems()
|
||||
}
|
||||
];
|
||||
this.model = mItems;
|
||||
@ -124,16 +119,28 @@ export class AppMenuComponent implements OnInit {
|
||||
{
|
||||
id: 'Help',
|
||||
label: $localize`:@@help:Help`, icon: 'help_outline',
|
||||
items: [{
|
||||
label: $localize`:@@trainingVideos:Training Videos`,
|
||||
icon: 'video_library',
|
||||
url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV',
|
||||
target: '_blank'
|
||||
}]
|
||||
items: this.buildHelpMenuItems()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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[]) {
|
||||
if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) {
|
||||
mItems.push(
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 { }
|
||||
@ -20,8 +20,6 @@
|
||||
<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>
|
||||
<label style="margin-right: 8px;">Self Signup Accounts {{ isSelfSignup ? 'On' : 'Off' }}</label>
|
||||
<p-inputSwitch [(ngModel)]="isSelfSignup" (onChange)="onToggle($event)"></p-inputSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@ -35,7 +35,6 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
partners: SelectItem[];
|
||||
cols: any[];
|
||||
totalItems;
|
||||
isSelfSignup = false;
|
||||
|
||||
searchAccordionOpen = sessionStorage.getItem('customers-list-accordion') === 'true';
|
||||
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: 'contact', label: globals.contact, dataType: 'text' },
|
||||
{ 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() {
|
||||
const saved = localStorage.getItem('isSelfSignup');
|
||||
this.isSelfSignup = saved === 'true';
|
||||
|
||||
this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => {
|
||||
this.setCustomersAndPartners(customers);
|
||||
});
|
||||
@ -105,8 +105,7 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy
|
||||
}
|
||||
|
||||
private setCustomersAndPartners(customers: Customer[]) {
|
||||
const filtered = this.isSelfSignup ? customers.filter(c => c.selfSignup) : customers;
|
||||
this.customers = filtered.map(c => ({
|
||||
this.customers = customers.map(c => ({
|
||||
...c,
|
||||
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) {
|
||||
this.store.dispatch(new customerActions.Select(event.data));
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
});
|
||||
|
||||
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}`;
|
||||
|
||||
const authReq = req.clone({ url: url, headers: headers });
|
||||
|
||||
@ -30,7 +30,7 @@ export const getEntityState = createFeatureSelector<EntityState>(FEATURE_KEY);
|
||||
|
||||
export const getCropsState = createSelector(
|
||||
getEntityState,
|
||||
state => state.crops
|
||||
state => state ? state.crops : fromCrops.initialState
|
||||
)
|
||||
export const {
|
||||
selectIds: getCropIds,
|
||||
@ -44,7 +44,7 @@ export const getCropsLoading = createSelector(getCropsState, fromCrops.getIsLoad
|
||||
|
||||
export const getPilotsState = createSelector(
|
||||
getEntityState,
|
||||
state => state.pilots
|
||||
state => state ? state.pilots : fromPilots.initialState
|
||||
)
|
||||
export const {
|
||||
selectIds: getPilotIds,
|
||||
@ -56,7 +56,7 @@ export const {
|
||||
|
||||
export const getProductsState = createSelector(
|
||||
getEntityState,
|
||||
state => state.products
|
||||
state => state ? state.products : fromProducts.initialState
|
||||
)
|
||||
export const {
|
||||
selectIds: getProductIds,
|
||||
@ -68,7 +68,7 @@ export const {
|
||||
|
||||
export const getVehilesState = createSelector(
|
||||
getEntityState,
|
||||
state => state.vehicles
|
||||
state => state ? state.vehicles : fromVehicles.initialState
|
||||
)
|
||||
export const {
|
||||
selectIds: getVehicleIds,
|
||||
|
||||
@ -172,7 +172,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
|
||||
{ key: 'name', label: globals.name, dataType: 'text' 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: '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 },
|
||||
];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -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>();
|
||||
}
|
||||
@ -3,6 +3,25 @@
|
||||
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 {
|
||||
border: 1px solid lightgray;
|
||||
}
|
||||
@ -42,3 +61,193 @@
|
||||
.loc-time {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@
|
||||
<ng-template pTemplate="paginatorleft" let-state>
|
||||
{{ state.totalRecords | i18nPlural: totalItems }}
|
||||
</ng-template>
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<ng-template pTemplate="empty sage">
|
||||
<tr>
|
||||
<td [attr.colspan]="4">
|
||||
<div class="ui-messages-error" *ngIf="!hasItems()">
|
||||
@ -157,6 +157,171 @@
|
||||
</p-toolbar>
|
||||
</div>
|
||||
<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>
|
||||
@ -212,8 +377,16 @@
|
||||
<label for="width" i18n="@@width">Width</label>
|
||||
</div>
|
||||
<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">
|
||||
<span>{{ job.measureUnit | lengthUnit }}</span>
|
||||
<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>
|
||||
</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>
|
||||
@ -583,6 +756,8 @@
|
||||
</div>
|
||||
</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">
|
||||
<div class="ui-g-12 ui-g-nopad">
|
||||
<p-toolbar>
|
||||
@ -627,244 +802,11 @@
|
||||
</div>
|
||||
</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">
|
||||
<p-toggleButton [(ngModel)]="centerPlayPos" onIcon="ui-icon-center-focus-strong" offIcon="ui-icon-center-focus-strong" styleClass="round-button" onLabel="" offLabel="">
|
||||
</p-toggleButton>
|
||||
</div>
|
||||
</div>
|
||||
</p-toolbar>
|
||||
</div>
|
||||
<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>
|
||||
<app-playback-panel #playbackPanel
|
||||
[job]="currentJob"
|
||||
[map]="map"
|
||||
[settings]="settings"
|
||||
[totalArea]="totalArea"
|
||||
[totalAmount]="totalAmount">
|
||||
</app-playback-panel>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -24,6 +24,7 @@ import { TabViewModule } from 'primeng/tabview';
|
||||
import { SliderModule } from 'primeng/slider';
|
||||
import { OrderListModule } from 'primeng/orderlist';
|
||||
import { AccordionModule } from 'primeng/accordion';
|
||||
import { SelectButtonModule } from 'primeng/selectbutton';
|
||||
|
||||
import { StoreModule } from '@ngrx/store';
|
||||
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 { JobAssignmentComponent } from './job-assignment/job-assignment.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 { InvoicesModule } from '@app/invoices/invoices.module';
|
||||
|
||||
@ -48,14 +50,14 @@ import { InvoicesModule } from '@app/invoices/invoices.module';
|
||||
PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule,
|
||||
CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule,
|
||||
CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule, AccordionModule,
|
||||
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule,
|
||||
PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule, SelectButtonModule,
|
||||
|
||||
JobsRoutingModule,
|
||||
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
|
||||
StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer),
|
||||
EffectsModule.forFeature([JobEffects, ClientEffects]), InvoicesModule,
|
||||
],
|
||||
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
|
||||
declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent, BufEditorPanelComponent],
|
||||
providers: [DatePipe],
|
||||
schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
|
||||
@ -29,6 +29,12 @@ export interface BufferZone {
|
||||
type: ITEM;
|
||||
name?: string;
|
||||
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 {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { AuthGuard } from '../domain/guards/auth.guard';
|
||||
import { ChangelogComponent } from './changelog.component';
|
||||
import { ReleaseNotesComponent } from './release-notes.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ChangelogComponent,
|
||||
component: ReleaseNotesComponent,
|
||||
canActivate: [AuthGuard]
|
||||
}
|
||||
];
|
||||
@ -15,4 +15,4 @@ const routes: Routes = [
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class ChangelogRoutingModule { }
|
||||
export class ReleaseNotesRoutingModule { }
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 ? '▾' : '▸' }}</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 ? '❮' : '❯' }}</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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 { }
|
||||
@ -181,17 +181,17 @@
|
||||
(click)="openNewDialog()">
|
||||
</button>
|
||||
<button type="button" pButton icon="ui-icon-refresh"
|
||||
[disabled]="!selectedKey"
|
||||
[disabled]="!keys.length || !selectedKey"
|
||||
i18n-label="@@regenerateKey" label="Regenerate"
|
||||
(click)="confirmRegenerate(selectedKey)">
|
||||
</button>
|
||||
<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"
|
||||
(click)="confirmRevoke(selectedKey)">
|
||||
</button>
|
||||
<button type="button" pButton icon="ui-icon-trash"
|
||||
[disabled]="!selectedKey"
|
||||
[disabled]="!keys.length || !selectedKey"
|
||||
i18n-label="@@deleteKey" label="Delete"
|
||||
(click)="confirmDelete(selectedKey)">
|
||||
</button>
|
||||
|
||||
@ -45,6 +45,7 @@ export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestro
|
||||
cols: any[] = [];
|
||||
expandedRows: { [id: string]: boolean } = {};
|
||||
selectedKey: ApiKey | null = null;
|
||||
keys: ApiKey[] = [];
|
||||
showNewDialog = false;
|
||||
|
||||
createdAtFilter: Date | null = null;
|
||||
@ -81,6 +82,8 @@ export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestro
|
||||
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.isMasterAccount = this.authSvc.hasRole([RoleIds.APP]);
|
||||
|
||||
@ -223,6 +226,7 @@ export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestro
|
||||
header: $localize`:@@deleteKey:Delete Key`,
|
||||
icon: 'pi pi-trash',
|
||||
accept: () => {
|
||||
this.selectedKey = null;
|
||||
this.store.dispatch(ApiKeyActions.deleteApiKey({ keyId: key._id, ownerId: this.ownerId }));
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
import { SettingsRoutingModule } from './settings-routing.module';
|
||||
import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component';
|
||||
@ -30,7 +29,6 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
HttpClientModule,
|
||||
SettingsRoutingModule,
|
||||
AppSharedModule,
|
||||
// PrimeNG
|
||||
|
||||
@ -9,6 +9,7 @@ import { DropdownModule } from 'primeng/dropdown';
|
||||
import { CheckboxModule } from 'primeng/checkbox';
|
||||
import { KeyFilterModule } from 'primeng/keyfilter';
|
||||
import { PanelModule } from 'primeng/panel';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
import { MessagesModule } from 'primeng/messages';
|
||||
import { MessageModule } from 'primeng/message';
|
||||
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 { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component';
|
||||
import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.component';
|
||||
import { MarkdownViewerComponent } from './markdown-viewer/markdown-viewer.component';
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule,
|
||||
MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule,
|
||||
ProgressSpinnerModule, MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule,
|
||||
MultiSelectModule, FormsModule
|
||||
],
|
||||
declarations: [
|
||||
@ -93,7 +95,7 @@ import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.componen
|
||||
JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe,
|
||||
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,
|
||||
DynamicFilterComponent
|
||||
DynamicFilterComponent, MarkdownViewerComponent
|
||||
],
|
||||
exports: [
|
||||
CommonModule, GlobalModule, SharedModule, ReactiveFormsModule, FormsModule,
|
||||
@ -105,7 +107,7 @@ import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.componen
|
||||
DebounceDirective, UnitIdUniqueDirective,
|
||||
ProfileFormComponent, CreditcardFormComponent, CardInfoComponent,
|
||||
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]
|
||||
})
|
||||
|
||||
@ -316,7 +316,10 @@ export class MapBaseComp extends BaseComp implements OnDestroy {
|
||||
color: props.color,
|
||||
area: parseFloat(props.area),
|
||||
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.area)
|
||||
@ -415,4 +418,7 @@ export interface MapItem {
|
||||
client?: string;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
offset?: number;
|
||||
edgeSide?: 'on' | 'inside' | 'outside';
|
||||
edgeSign?: number;
|
||||
}
|
||||
|
||||
@ -363,16 +363,21 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
|
||||
xclArea += dA;
|
||||
}
|
||||
} 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();
|
||||
if (llns.length) {
|
||||
let ll;
|
||||
for (let k = 0; k < 1; k++) {
|
||||
ll = llns[k];
|
||||
if (turf.booleanPointInPolygon([ll.lng, ll.lat], sprayPoly)) {
|
||||
const firstEl = llns[0];
|
||||
if (Array.isArray(firstEl)) {
|
||||
// Polygon-type buffer (e.g. edge buffer zone) — use turf.intersect for accuracy
|
||||
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();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -446,7 +451,7 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
|
||||
this.postTypeChanged(e);
|
||||
}
|
||||
|
||||
protected getDefaultName(layer) {
|
||||
protected getDefaultName(layer, extraOffset = 0) {
|
||||
if (!layer.feature || !layer.feature.properties) {
|
||||
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)}`;
|
||||
}
|
||||
@ -555,7 +560,10 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy {
|
||||
else {
|
||||
if (type === ITEM.BUFFER) {
|
||||
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 {
|
||||
setTimeout(() => this.map.setView((<any>layer).getLatLng(), Math.min(GC.MAX_ZOOM_ITEM, this.map.getZoom())), 200);
|
||||
if (!layer.isTooltipOpen())
|
||||
|
||||
@ -105,6 +105,14 @@
|
||||
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 {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
|
||||
@ -26,9 +26,11 @@
|
||||
<!-- Header: label + remove -->
|
||||
<div class="filter-field-header">
|
||||
<label>{{ filter.definition.label }}</label>
|
||||
<button pButton type="button" icon="pi pi-times" class="ui-button-text remove-btn"
|
||||
(click)="removeFilter(filter.id)">
|
||||
</button>
|
||||
<span class="filter-field-header-action">
|
||||
<button *ngIf="isFilterRemovable(filter)" pButton type="button" icon="pi pi-times" class="ui-button-text remove-btn"
|
||||
(click)="removeFilter(filter.id)">
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-field-body">
|
||||
@ -112,15 +114,17 @@
|
||||
</p-dropdown>
|
||||
<ng-container *ngIf="isDatePresetCustom(filter.id)">
|
||||
<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>
|
||||
<span *ngIf="!filter.value" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
|
||||
<span *ngIf="filter.value">{{ filter.value | 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]" class="date-placeholder" i18n="@@selectDate">Select Date...</span>
|
||||
<span *ngIf="filter.value && filter.value[0] && !filter.value[1]">{{ filter.value[0] | date:'shortDate' }}</span>
|
||||
<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>
|
||||
<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()"
|
||||
(onClearClick)="onValueChange()" [showButtonBar]="true">
|
||||
(onClearClick)="onValueChange()" [showButtonBar]="true"
|
||||
selectionMode="range" [readonlyInput]="true">
|
||||
</p-calendar>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@ -14,6 +14,7 @@ export interface FilterDefinition {
|
||||
label: string;
|
||||
dataType: FilterDataType;
|
||||
options?: SelectItem[];
|
||||
removable?: boolean;
|
||||
}
|
||||
|
||||
export type FilterOperator = 'and' | 'or';
|
||||
@ -80,10 +81,14 @@ export function buildFilterQuery(activeFilters: ActiveFilter[]): Record<string,
|
||||
if (f.definition.dataType === 'date' && f.valueOperator === 'range'
|
||||
&& (!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' && Array.isArray(f.value) && !f.value[0]) { continue; }
|
||||
|
||||
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] = {
|
||||
value: f.value,
|
||||
value: queryValue,
|
||||
operator: f.operator,
|
||||
...(hasValueOperator ? { valueOperator: f.valueOperator } : {}),
|
||||
dataType: f.definition.dataType,
|
||||
@ -101,7 +106,7 @@ export function buildFilterQuery(activeFilters: ActiveFilter[]): Record<string,
|
||||
export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||
@Input() filterDefinitions: FilterDefinition[] = [];
|
||||
@Input() locale: any = {};
|
||||
@Input() stateKey: string;
|
||||
@Input() stateKey?: string;
|
||||
@Input() defaultFilters: Array<{ key: string; value: any }> = [];
|
||||
@Input() showSearch = true;
|
||||
@Input() autoSaveOnChange = false;
|
||||
@ -180,6 +185,9 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||
}
|
||||
|
||||
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.datePresetSelected.delete(id);
|
||||
this.buildAvailableFilters();
|
||||
@ -205,11 +213,17 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.activeFilters = [];
|
||||
this.activeFilters = this.activeFilters
|
||||
.filter((filter: ActiveFilter) => !this.isFilterRemovable(filter))
|
||||
.map((filter: ActiveFilter) => this.resetFilter(filter));
|
||||
this.selectedFilterKey = null;
|
||||
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.clearState();
|
||||
this.emitChange();
|
||||
this.submit();
|
||||
}
|
||||
@ -235,6 +249,10 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||
return this.datePresetSelected.get(filterId) === 'custom';
|
||||
}
|
||||
|
||||
isFilterRemovable(filter: ActiveFilter): boolean {
|
||||
return filter.definition.removable !== false;
|
||||
}
|
||||
|
||||
openCal(filterId: number, isRange: boolean): void {
|
||||
const attr = isRange ? `data-filter-cal-range` : `data-filter-cal`;
|
||||
const calHost = this.el.nativeElement.querySelector(`[${attr}="${filterId}"]`);
|
||||
@ -276,13 +294,18 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||
case 'select': return null;
|
||||
case 'select-multi': return [];
|
||||
case 'date': return op === 'range' ? null : null;
|
||||
case 'date-preset': return '1m';
|
||||
case 'date-preset': return null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
private saveState(): void {
|
||||
if (!this.stateKey) { return; }
|
||||
if (!this.activeFilters.length) {
|
||||
this.clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.activeFilters.map(f => ({
|
||||
key: f.definition.key,
|
||||
value: f.value,
|
||||
@ -303,19 +326,13 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||
for (const df of this.defaultFilters) {
|
||||
const def = this.filterDefinitions.find(f => f.key === df.key);
|
||||
if (!def) { continue; }
|
||||
const defaultOp = DEFAULT_VALUE_OPERATOR[def.dataType];
|
||||
const filter: ActiveFilter = {
|
||||
id: this.nextId++,
|
||||
definition: def,
|
||||
value: df.value,
|
||||
operator: 'and',
|
||||
valueOperator: defaultOp
|
||||
};
|
||||
const filter = this.createFilter(def, df.value);
|
||||
this.activeFilters.push(filter);
|
||||
if (def.dataType === 'date-preset') {
|
||||
this.datePresetSelected.set(filter.id, df.value);
|
||||
}
|
||||
}
|
||||
this.ensureRequiredFilters();
|
||||
if (this.activeFilters.length) {
|
||||
this.buildAvailableFilters();
|
||||
this.submit();
|
||||
@ -346,9 +363,7 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||
if (!def) { continue; }
|
||||
|
||||
const filter: ActiveFilter = {
|
||||
id: this.nextId++,
|
||||
definition: def,
|
||||
value: this.deserializeValue(entry.value, def.dataType, entry.valueOperator),
|
||||
...this.createFilter(def, this.deserializeValue(entry.value, def.dataType, entry.valueOperator)),
|
||||
operator: entry.operator || 'and',
|
||||
valueOperator: entry.valueOperator || DEFAULT_VALUE_OPERATOR[def.dataType]
|
||||
};
|
||||
@ -359,12 +374,55 @@ export class DynamicFilterComponent implements OnInit, OnChanges {
|
||||
}
|
||||
}
|
||||
|
||||
this.ensureRequiredFilters();
|
||||
|
||||
if (this.activeFilters.length) {
|
||||
this.buildAvailableFilters();
|
||||
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 {
|
||||
if (value == null) { return 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') {
|
||||
return new Date(value);
|
||||
}
|
||||
if (dataType === 'date-preset' && Array.isArray(value)) {
|
||||
return value.map(v => v ? new Date(v) : null);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 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 {
|
||||
GeoItems = 'GeoItems', SprayZones = 'SprayZones', XCLZones = 'XCLZones', GridLines = 'GridLines', FlightPaths = 'FlightPaths', SprayData = 'SprayData',
|
||||
Tracks = 'Tracks', ABLine = 'ABLine', Obstacles = 'Obstacles'
|
||||
@ -628,6 +628,7 @@ export const globals = Object.freeze({
|
||||
xclZone: $localize`:@@xclZone:Exclusion Zone`,
|
||||
waypoint: $localize`:@@waypoint:WayPoint`,
|
||||
bufferZone: $localize`:@@bufferZone:Buffer Zone`,
|
||||
edgeBufferZone: $localize`:@@edgeBufferZone:Advanced Buffer Tools`,
|
||||
placeMark: $localize`:@@placeMark:PlaceMark`,
|
||||
obstacle: $localize`:@@obstacle:Obstacle`,
|
||||
userObstacle: $localize`:@@userObstacle:User Obstacles`,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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">▴</button>
|
||||
<button class="markdown-viewer__find-nav" (click)="navigateMatch(1)" title="Next match">▾</button>
|
||||
</ng-container>
|
||||
<button *ngIf="searchQuery" class="markdown-viewer__find-clear" (click)="clearSearch()" title="Clear">✕</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>
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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, '-');
|
||||
}
|
||||
}
|
||||
1
Development/client/src/assets/js/Leaflet.river.js
Normal file
1
Development/client/src/assets/js/Leaflet.river.js
Normal 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)};
|
||||
826
Development/client/src/assets/js/leaflet.geometryutil.js
Normal file
826
Development/client/src/assets/js/leaflet.geometryutil.js
Normal 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;
|
||||
|
||||
}));
|
||||
227
Development/client/src/assets/js/leaflet.polylineoffset.js
Normal file
227
Development/client/src/assets/js/leaflet.polylineoffset.js
Normal 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));
|
||||
669
Development/client/src/assets/js/leaflet.snap.js
Normal file
669
Development/client/src/assets/js/leaflet.snap.js
Normal 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
5
Development/client/src/mermaid.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare module 'mermaid' {
|
||||
const mermaid: any;
|
||||
|
||||
export default mermaid;
|
||||
}
|
||||
@ -151,6 +151,21 @@ span.align-enter {
|
||||
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 {
|
||||
background-color: white;
|
||||
width: 14px;
|
||||
|
||||
@ -361,11 +361,7 @@ async function generateExport(exportJobId) {
|
||||
} catch (err) {
|
||||
exportJob.status = ExportJobStatus.ERROR;
|
||||
exportJob.errorMsg = err.message;
|
||||
try {
|
||||
await exportJob.save();
|
||||
} catch (saveErr) {
|
||||
// ExportJob may have been deleted before we could update it – ignore
|
||||
}
|
||||
await exportJob.save();
|
||||
console.error('[export] generation failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ const CUSTOMER_FILTER_SCHEMA = {
|
||||
email: 'text',
|
||||
contact: 'text',
|
||||
createdAt: 'date-preset',
|
||||
selfSignup: 'select',
|
||||
};
|
||||
|
||||
async function getCustomers_get(req, res) {
|
||||
|
||||
@ -246,7 +246,7 @@
|
||||
],
|
||||
"body": {
|
||||
"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" } }
|
||||
},
|
||||
"url": {
|
||||
@ -254,7 +254,43 @@
|
||||
"host": ["{{baseUrl}}"],
|
||||
"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": {
|
||||
"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" } }
|
||||
},
|
||||
"url": {
|
||||
@ -290,7 +326,7 @@
|
||||
"host": ["{{baseUrl}}"],
|
||||
"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": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"format\": \"geojson\"\n}",
|
||||
"raw": "{\n \"format\": \"geojson\",\n \"fm\": false\n}",
|
||||
"options": { "raw": { "language": "json" } }
|
||||
},
|
||||
"url": {
|
||||
|
||||
131
Development/server/docs/MARKDOWN_VIEWER.md
Normal file
131
Development/server/docs/MARKDOWN_VIEWER.md
Normal 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`.
|
||||
@ -104,8 +104,8 @@ const schema = new Schema({
|
||||
bufs: [{
|
||||
properties: { type: Schema.Types.Mixed },
|
||||
geometry: {
|
||||
type: { type: String, required: true },
|
||||
coordinates: { type: [[Number]], required: true }
|
||||
type: { type: String, required: true },
|
||||
coordinates: { type: Schema.Types.Mixed, required: true }
|
||||
}
|
||||
}],
|
||||
waypoints: [{
|
||||
@ -180,7 +180,6 @@ schema.plugin(AutoIncrement, { inc_field: '_id' });
|
||||
schema.index({ 'sprayAreas.geometry': '2dsphere' });
|
||||
schema.index({ 'excludedAreas.geometry': '2dsphere' });
|
||||
*/
|
||||
schema.index({ 'bufs.geometry': '2dsphere' });
|
||||
schema.index({ 'waypoints.geometry': '2dsphere' });
|
||||
schema.index({ 'places.geometry': '2dsphere' });
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user