manage file style

This commit is contained in:
vcoppe
2024-06-19 16:15:21 +02:00
parent b0373b2858
commit a8ef6dcae1
8 changed files with 356 additions and 36 deletions

View File

@@ -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" />

View File

@@ -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;

View File

@@ -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" />

View File

@@ -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;

View File

@@ -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;
}

View 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": {