mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-09-01 08:12:32 +00:00
manage file style
This commit is contained in:
@@ -3,11 +3,12 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { dbUtils, getFile, settings } from '$lib/db';
|
||||
import { Copy, Info, MapPin, Plus, Save, Trash2, Waypoints } from 'lucide-svelte';
|
||||
import { Copy, Info, MapPin, PaintBucket, Plus, Save, Trash2, Waypoints } from 'lucide-svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListLevel,
|
||||
@@ -16,10 +17,9 @@
|
||||
type ListItem
|
||||
} from './FileList';
|
||||
import { selectItem, selection } from './Selection';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { gpxLayers } from '$lib/stores';
|
||||
import { gpxLayers, map } from '$lib/stores';
|
||||
import {
|
||||
GPXTreeElement,
|
||||
Track,
|
||||
@@ -28,6 +28,7 @@
|
||||
Waypoint,
|
||||
GPXFile
|
||||
} from 'gpx';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let node:
|
||||
| GPXTreeElement<AnyGPXTreeElement>
|
||||
@@ -35,14 +36,16 @@
|
||||
| Readonly<Waypoint>;
|
||||
export let item: ListItem;
|
||||
export let label: string | undefined;
|
||||
let nodeColors: string[] = [];
|
||||
|
||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||
|
||||
const { verticalFileView } = settings;
|
||||
const { verticalFileView, defaultOpacity, defaultWeight } = settings;
|
||||
|
||||
$: singleSelection = $selection.size === 1;
|
||||
|
||||
let openEditMetadata: boolean = false;
|
||||
let openEditStyle: boolean = false;
|
||||
let name: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.name ?? ''
|
||||
@@ -55,6 +58,111 @@
|
||||
: node instanceof Track
|
||||
? node.desc ?? ''
|
||||
: '';
|
||||
let colors: string[] = [];
|
||||
let color: string | undefined = undefined;
|
||||
let opacity: number[] = [];
|
||||
let weight: number[] = [];
|
||||
let colorChanged = false;
|
||||
let opacityChanged = false;
|
||||
let weightChanged = false;
|
||||
|
||||
$: if (node && $map) {
|
||||
nodeColors = [];
|
||||
|
||||
if (node instanceof GPXFile) {
|
||||
let style = node.getStyle();
|
||||
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
style.color.push(layer.layerColor);
|
||||
}
|
||||
|
||||
style.color.forEach((c) => {
|
||||
if (!nodeColors.includes(c)) {
|
||||
nodeColors.push(c);
|
||||
}
|
||||
});
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (style) {
|
||||
if (style.color && !nodeColors.includes(style.color)) {
|
||||
nodeColors.push(style.color);
|
||||
}
|
||||
}
|
||||
if (nodeColors.length === 0) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
nodeColors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStyleInputs() {
|
||||
colors = [];
|
||||
opacity = [];
|
||||
weight = [];
|
||||
|
||||
$selection.forEach((item) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (file && layer) {
|
||||
let style = file.getStyle();
|
||||
style.color.push(layer.layerColor);
|
||||
|
||||
style.color.forEach((c) => {
|
||||
if (!colors.includes(c)) {
|
||||
colors.push(c);
|
||||
}
|
||||
});
|
||||
style.opacity.forEach((o) => {
|
||||
if (!opacity.includes(o)) {
|
||||
opacity.push(o);
|
||||
}
|
||||
});
|
||||
style.weight.forEach((w) => {
|
||||
if (!weight.includes(w)) {
|
||||
weight.push(w);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
let file = getFile(item.getFileId());
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (file && layer) {
|
||||
let track = file.trk[item.getTrackIndex()];
|
||||
let style = track.getStyle();
|
||||
if (style) {
|
||||
if (style.color && !colors.includes(style.color)) {
|
||||
colors.push(style.color);
|
||||
}
|
||||
if (style.opacity && !opacity.includes(style.opacity)) {
|
||||
opacity.push(style.opacity);
|
||||
}
|
||||
if (style.weight && !weight.includes(style.weight)) {
|
||||
weight.push(style.weight);
|
||||
}
|
||||
}
|
||||
if (!colors.includes(layer.layerColor)) {
|
||||
colors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
color = colors[0];
|
||||
opacity = [opacity[0] ?? $defaultOpacity];
|
||||
weight = [weight[0] ?? $defaultWeight];
|
||||
|
||||
colorChanged = false;
|
||||
opacityChanged = false;
|
||||
weightChanged = false;
|
||||
}
|
||||
|
||||
$: if ($selection && openEditStyle) {
|
||||
setStyleInputs();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
@@ -103,6 +211,76 @@
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<Popover.Root bind:open={openEditStyle}>
|
||||
<Popover.Trigger />
|
||||
<Popover.Content side="top" sideOffset={22} class="flex flex-col gap-3">
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{$_('menu.style.color')}
|
||||
<Input
|
||||
bind:value={color}
|
||||
type="color"
|
||||
class="p-0 h-6 w-40"
|
||||
on:change={() => (colorChanged = true)}
|
||||
/>
|
||||
</Label>
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{$_('menu.style.opacity')}
|
||||
<div class="w-40 p-2">
|
||||
<Slider
|
||||
bind:value={opacity}
|
||||
min={0.3}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onValueChange={() => (opacityChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||
{$_('menu.style.weight')}
|
||||
<div class="w-40 p-2">
|
||||
<Slider
|
||||
bind:value={weight}
|
||||
id="weight"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={() => (weightChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!colorChanged && !opacityChanged && !weightChanged}
|
||||
on:click={() => {
|
||||
let style = {};
|
||||
if (colorChanged) {
|
||||
style.color = color;
|
||||
}
|
||||
if (opacityChanged) {
|
||||
style.opacity = opacity[0];
|
||||
}
|
||||
if (weightChanged) {
|
||||
style.weight = weight[0];
|
||||
}
|
||||
dbUtils.setStyleToSelection(style);
|
||||
|
||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||
if (style.opacity) {
|
||||
$defaultOpacity = style.opacity;
|
||||
}
|
||||
if (style.weight) {
|
||||
$defaultWeight = style.weight;
|
||||
}
|
||||
}
|
||||
|
||||
openEditStyle = false;
|
||||
}}
|
||||
>
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('menu.metadata.save')}
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/if}
|
||||
<span
|
||||
class="w-full text-left truncate py-1 flex flex-row items-center"
|
||||
@@ -135,7 +313,19 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if item.level === ListLevel.SEGMENT}
|
||||
{#if item.level === ListLevel.FILE}
|
||||
<div
|
||||
class="h-[10px] w-[10px] rounded-[3px] mr-1 mt-[1px]"
|
||||
style="background:conic-gradient({nodeColors
|
||||
.map(
|
||||
(c, i) =>
|
||||
`${c} ${(360 * i) / nodeColors.length}deg, ${c} ${(360 * (i + 1)) / nodeColors.length}deg`
|
||||
)
|
||||
.join(',')})"
|
||||
/>
|
||||
{:else if item.level === ListLevel.TRACK}
|
||||
<div class="h-[10px] w-[10px] rounded-[3px] mr-1" style="background:{nodeColors[0]}" />
|
||||
{:else if item.level === ListLevel.SEGMENT}
|
||||
<Waypoints size="16" class="mr-1 shrink-0" />
|
||||
{:else if item.level === ListLevel.WAYPOINT}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
@@ -147,6 +337,17 @@
|
||||
</Button>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Content>
|
||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||
<ContextMenu.Item disabled={!singleSelection} on:click={() => (openEditMetadata = true)}>
|
||||
<Info size="16" class="mr-1" />
|
||||
{$_('menu.metadata.button')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item on:click={() => (openEditStyle = true)}>
|
||||
<PaintBucket size="16" class="mr-1" />
|
||||
{$_('menu.style.button')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
{#if $verticalFileView}
|
||||
{#if item instanceof ListFileItem}
|
||||
<ContextMenu.Item
|
||||
@@ -160,6 +361,7 @@
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_track')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{:else if item instanceof ListTrackItem}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
@@ -180,15 +382,9 @@
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_segment')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||
<ContextMenu.Item disabled={!singleSelection} on:click={() => (openEditMetadata = true)}>
|
||||
<Info size="16" class="mr-1" />
|
||||
{$_('menu.metadata.button')}
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
{#if item.level !== ListLevel.WAYPOINTS}
|
||||
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
|
||||
<Copy size="16" class="mr-1" />
|
||||
|
@@ -11,9 +11,6 @@ import { resetCursor, setCursor, setGrabbingCursor, setPointerCursor } from "$li
|
||||
import { font } from "$lib/assets/layers";
|
||||
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
|
||||
|
||||
let defaultWeight = 5;
|
||||
let defaultOpacity = 0.6;
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
'#0000ff',
|
||||
@@ -41,10 +38,12 @@ function getColor() {
|
||||
}
|
||||
|
||||
function decrementColor(color: string) {
|
||||
colorCount[color]--;
|
||||
if (colorCount.hasOwnProperty(color)) {
|
||||
colorCount[color]--;
|
||||
}
|
||||
}
|
||||
|
||||
const { directionMarkers, verticalFileView, currentBasemap } = settings;
|
||||
const { directionMarkers, verticalFileView, currentBasemap, defaultOpacity, defaultWeight } = settings;
|
||||
|
||||
export class GPXLayer {
|
||||
map: mapboxgl.Map;
|
||||
@@ -99,6 +98,11 @@ export class GPXLayer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file._data.style && file._data.style.color && this.layerColor !== file._data.style.color) {
|
||||
decrementColor(this.layerColor);
|
||||
this.layerColor = file._data.style.color;
|
||||
}
|
||||
|
||||
try {
|
||||
let source = this.map.getSource(this.fileId);
|
||||
if (source) {
|
||||
@@ -358,7 +362,6 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
@@ -379,14 +382,14 @@ export class GPXLayer {
|
||||
feature.properties.color = this.layerColor;
|
||||
}
|
||||
if (!feature.properties.weight) {
|
||||
feature.properties.weight = defaultWeight;
|
||||
feature.properties.weight = get(defaultWeight);
|
||||
}
|
||||
if (!feature.properties.opacity) {
|
||||
feature.properties.opacity = defaultOpacity;
|
||||
feature.properties.opacity = get(defaultOpacity);
|
||||
}
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)) || get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)) {
|
||||
feature.properties.weight = feature.properties.weight + 2;
|
||||
feature.properties.opacity = (feature.properties.opacity + 2) / 3;
|
||||
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
|
||||
}
|
||||
feature.properties.trackIndex = trackIndex;
|
||||
feature.properties.segmentIndex = segmentIndex;
|
||||
|
@@ -120,7 +120,7 @@
|
||||
<Accordion.Trigger>{$_('layers.heatmap')}</Accordion.Trigger>
|
||||
<Accordion.Content class="overflow-visible">
|
||||
<Label class="flex flex-row items-center justify-between gap-4"
|
||||
>{$_('menu.color')}
|
||||
>{$_('menu.style.color')}
|
||||
<Select.Root bind:selected={$selectedHeatmapColor}>
|
||||
<Select.Trigger class="h-8 mr-1">
|
||||
<Select.Value placeholder="Theme" />
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import Dexie, { liveQuery } from 'dexie';
|
||||
import { GPXFile, GPXStatistics, Track, TrackSegment, Waypoint, TrackPoint, type Coordinates, distance } from 'gpx';
|
||||
import { GPXFile, GPXStatistics, Track, TrackSegment, Waypoint, TrackPoint, type Coordinates, distance, type LineStyleExtension } from 'gpx';
|
||||
import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, castDraft, freeze, produceWithPatches, original, produce } from 'immer';
|
||||
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
|
||||
import { gpxStatistics, initTargetMapBounds, splitAs, updateTargetMapBounds } from './stores';
|
||||
@@ -102,6 +102,8 @@ export const settings = {
|
||||
distanceMarkers: dexieSettingStore('distanceMarkers', false),
|
||||
stravaHeatmapColor: dexieSettingStore('stravaHeatmapColor', 'bluered'),
|
||||
fileOrder: dexieSettingStore<string[]>('fileOrder', []),
|
||||
defaultOpacity: dexieSettingStore('defaultOpacity', 0.6),
|
||||
defaultWeight: dexieSettingStore('defaultWeight', 5),
|
||||
};
|
||||
|
||||
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber
|
||||
@@ -826,6 +828,31 @@ export const dbUtils = {
|
||||
});
|
||||
});
|
||||
},
|
||||
setStyleToSelection: (style: LineStyleExtension) => {
|
||||
if (get(selection).size === 0) {
|
||||
return;
|
||||
}
|
||||
applyGlobal((draft) => {
|
||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = original(draft)?.get(fileId);
|
||||
if (file) {
|
||||
let newFile = file;
|
||||
if (level === ListLevel.FILE) {
|
||||
newFile = file.setStyle(style);
|
||||
} else if (level === ListLevel.TRACK) {
|
||||
for (let item of items) {
|
||||
let trackIndex = (item as ListTrackItem).getTrackIndex();
|
||||
newFile = newFile.replaceTracks(trackIndex, trackIndex, [file.trk[trackIndex].setStyle(style)])[0];
|
||||
}
|
||||
if (items.length === file.trk.length) {
|
||||
newFile = newFile.setStyle(style);
|
||||
}
|
||||
}
|
||||
draft.set(newFile._data.id, freeze(newFile));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
deleteSelection: () => {
|
||||
if (get(selection).size === 0) {
|
||||
return;
|
||||
|
@@ -133,8 +133,23 @@ export const currentTool = writable<Tool | null>(null);
|
||||
export const splitAs = writable(SplitType.FILES);
|
||||
|
||||
export function newGPXFile() {
|
||||
const newFileName = get(_)("menu.new_filename");
|
||||
|
||||
let file = new GPXFile();
|
||||
file.metadata.name = get(_)("menu.new_filename");
|
||||
|
||||
let maxNewFileNumber = 0;
|
||||
get(fileObservers).forEach((f) => {
|
||||
let file = get(f)?.file;
|
||||
if (file && file.metadata.name.startsWith(newFileName)) {
|
||||
let number = parseInt(file.metadata.name.split(' ').pop() ?? '0');
|
||||
if (!isNaN(number) && number > maxNewFileNumber) {
|
||||
maxNewFileNumber = number;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
file.metadata.name = `${newFileName} ${maxNewFileNumber + 1}`;
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
|
@@ -1,15 +1,9 @@
|
||||
{
|
||||
"menu": {
|
||||
"new": "New",
|
||||
"new_filename": "new",
|
||||
"new_filename": "New file",
|
||||
"new_track": "New track",
|
||||
"new_segment": "New segment",
|
||||
"metadata": {
|
||||
"button": "Edit info",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"save": "Save"
|
||||
},
|
||||
"load_desktop": "Load...",
|
||||
"load_drive": "Load from Google Drive...",
|
||||
"duplicate": "Duplicate",
|
||||
@@ -47,7 +41,18 @@
|
||||
"ctrl": "Ctrl",
|
||||
"click": "Click",
|
||||
"drag": "Drag",
|
||||
"color": "Color"
|
||||
"metadata": {
|
||||
"button": "Edit info",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"save": "Save"
|
||||
},
|
||||
"style": {
|
||||
"button": "Change style",
|
||||
"color": "Color",
|
||||
"opacity": "Opacity",
|
||||
"weight": "Weight"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
|
Reference in New Issue
Block a user