realistic timestamps option

This commit is contained in:
vcoppe
2024-08-29 18:37:06 +02:00
parent 920e7901f4
commit a1b5fe6352
2 changed files with 418 additions and 333 deletions

View File

@@ -350,6 +350,15 @@ export class GPXFile extends GPXTreeNode<Track>{
}); });
} }
createArtificialTimestamps(startTime: Date, totalTime: number, trackIndex?: number, segmentIndex?: number) {
let lastPoint = undefined;
this.trk.forEach((track, index) => {
if (trackIndex === undefined || trackIndex === index) {
track.createArtificialTimestamps(startTime, totalTime, lastPoint, segmentIndex);
}
});
}
setStyle(style: LineStyleExtension) { setStyle(style: LineStyleExtension) {
this.trk.forEach((track) => { this.trk.forEach((track) => {
track.setStyle(style); track.setStyle(style);
@@ -581,6 +590,17 @@ export class Track extends GPXTreeNode<TrackSegment> {
}); });
} }
createArtificialTimestamps(startTime: Date, totalTime: number, lastPoint: TrackPoint | undefined, segmentIndex?: number) {
this.trkseg.forEach((segment, index) => {
if (segmentIndex === undefined || segmentIndex === index) {
segment.createArtificialTimestamps(startTime, totalTime, lastPoint);
if (segment.trkpt.length > 0) {
lastPoint = segment.trkpt[segment.trkpt.length - 1];
}
}
});
}
setStyle(style: LineStyleExtension, force: boolean = true) { setStyle(style: LineStyleExtension, force: boolean = true) {
if (!this.extensions) { if (!this.extensions) {
this.extensions = {}; this.extensions = {};
@@ -944,6 +964,14 @@ export class TrackSegment extends GPXTreeLeaf {
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
} }
} }
createArtificialTimestamps(startTime: Date, totalTime: number, lastPoint: TrackPoint | undefined) {
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
let slope = og._computeSlope();
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, slope);
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
}
setHidden(hidden: boolean) { setHidden(hidden: boolean) {
this._data.hidden = hidden; this._data.hidden = hidden;
} }
@@ -1442,6 +1470,30 @@ function withShiftedAndCompressedTimestamps(points: TrackPoint[], speed: number,
}); });
} }
function withArtificialTimestamps(points: TrackPoint[], totalTime: number, lastPoint: TrackPoint | undefined, startTime: Date, slope: number[]): TrackPoint[] {
let weight = [];
let totalWeight = 0;
for (let i = 0; i < points.length - 1; i++) {
let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates());
let w = dist * (0.5 + 1 / (1 + Math.exp(- 0.2 * slope[i])));
weight.push(w);
totalWeight += w;
}
let last = lastPoint;
return points.map((point, i) => {
let pt = point.clone();
if (i === 0) {
pt.time = lastPoint?.time ?? startTime;
} else {
pt.time = new Date(last.time.getTime() + totalTime * 1000 * weight[i - 1] / totalWeight);
}
last = pt;
return pt;
});
}
function getTimestamp(a: TrackPoint, b: TrackPoint, speed: number): Date { function getTimestamp(a: TrackPoint, b: TrackPoint, speed: number): Date {
let dist = distance(a.getCoordinates(), b.getCoordinates()) / 1000; let dist = distance(a.getCoordinates(), b.getCoordinates()) / 1000;
return new Date(a.time.getTime() + 1000 * 3600 * dist / speed); return new Date(a.time.getTime() + 1000 * 3600 * dist / speed);

View File

@@ -1,363 +1,396 @@
<script lang="ts"> <script lang="ts">
import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte'; import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte'; import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
import { dbUtils, settings } from '$lib/db'; import { dbUtils, settings } from '$lib/db';
import { gpxStatistics } from '$lib/stores'; import { gpxStatistics } from '$lib/stores';
import { import {
distancePerHourToSecondsPerDistance, distancePerHourToSecondsPerDistance,
getConvertedVelocity, getConvertedVelocity,
milesToKilometers, milesToKilometers,
nauticalMilesToKilometers nauticalMilesToKilometers
} from '$lib/units'; } from '$lib/units';
import { CalendarDate, type DateValue } from '@internationalized/date'; import { CalendarDate, type DateValue } from '@internationalized/date';
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte'; import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { _, locale } from 'svelte-i18n'; import { _, locale } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { selection } from '$lib/components/file-list/Selection'; import { selection } from '$lib/components/file-list/Selection';
import { import {
ListFileItem, ListFileItem,
ListRootItem, ListRootItem,
ListTrackItem, ListTrackItem,
ListTrackSegmentItem ListTrackSegmentItem
} from '$lib/components/file-list/FileList'; } from '$lib/components/file-list/FileList';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
let startDate: DateValue | undefined = undefined; let startDate: DateValue | undefined = undefined;
let startTime: string | undefined = undefined; let startTime: string | undefined = undefined;
let endDate: DateValue | undefined = undefined; let endDate: DateValue | undefined = undefined;
let endTime: string | undefined = undefined; let endTime: string | undefined = undefined;
let movingTime: number | undefined = undefined; let movingTime: number | undefined = undefined;
let speed: number | undefined = undefined; let speed: number | undefined = undefined;
let artificial = false;
function toCalendarDate(date: Date): CalendarDate { function toCalendarDate(date: Date): CalendarDate {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate()); return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
} }
const { velocityUnits, distanceUnits } = settings; const { velocityUnits, distanceUnits } = settings;
function setSpeed(value: number) { function setSpeed(value: number) {
let speedValue = getConvertedVelocity(value); let speedValue = getConvertedVelocity(value);
if ($velocityUnits === 'speed') { if ($velocityUnits === 'speed') {
speedValue = parseFloat(speedValue.toFixed(2)); speedValue = parseFloat(speedValue.toFixed(2));
} }
speed = speedValue; speed = speedValue;
} }
function setGPXData() { function setGPXData() {
if ($gpxStatistics.global.time.start) { if ($gpxStatistics.global.time.start) {
startDate = toCalendarDate($gpxStatistics.global.time.start); startDate = toCalendarDate($gpxStatistics.global.time.start);
startTime = $gpxStatistics.global.time.start.toLocaleTimeString(); startTime = $gpxStatistics.global.time.start.toLocaleTimeString();
} else { } else {
startDate = undefined; startDate = undefined;
startTime = undefined; startTime = undefined;
} }
if ($gpxStatistics.global.time.end) { if ($gpxStatistics.global.time.end) {
endDate = toCalendarDate($gpxStatistics.global.time.end); endDate = toCalendarDate($gpxStatistics.global.time.end);
endTime = $gpxStatistics.global.time.end.toLocaleTimeString(); endTime = $gpxStatistics.global.time.end.toLocaleTimeString();
} else { } else {
endDate = undefined; endDate = undefined;
endTime = undefined; endTime = undefined;
} }
if ($gpxStatistics.global.time.moving) { if ($gpxStatistics.global.time.moving) {
movingTime = $gpxStatistics.global.time.moving; movingTime = $gpxStatistics.global.time.moving;
} else { } else {
movingTime = undefined; movingTime = undefined;
} }
if ($gpxStatistics.global.speed.moving) { if ($gpxStatistics.global.speed.moving) {
setSpeed($gpxStatistics.global.speed.moving); setSpeed($gpxStatistics.global.speed.moving);
} else { } else {
speed = undefined; speed = undefined;
} }
} }
$: if ($gpxStatistics && $velocityUnits && $distanceUnits) { $: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
setGPXData(); setGPXData();
} }
function getDate(date: DateValue, time: string): Date { function getDate(date: DateValue, time: string): Date {
if (date === undefined) { if (date === undefined) {
return new Date(); return new Date();
} }
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x)); let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds); return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
} }
function updateEnd() { function updateEnd() {
if (startDate && movingTime !== undefined) { if (startDate && movingTime !== undefined) {
if (startTime === undefined) { if (startTime === undefined) {
startTime = '00:00:00'; startTime = '00:00:00';
} }
let start = getDate(startDate, startTime); let start = getDate(startDate, startTime);
let ratio = let ratio =
$gpxStatistics.global.time.moving > 0 $gpxStatistics.global.time.moving > 0
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving ? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
: 1; : 1;
let end = new Date(start.getTime() + ratio * movingTime * 1000); let end = new Date(start.getTime() + ratio * movingTime * 1000);
endDate = toCalendarDate(end); endDate = toCalendarDate(end);
endTime = end.toLocaleTimeString(); endTime = end.toLocaleTimeString();
} }
} }
function updateStart() { function updateStart() {
if (endDate && movingTime !== undefined) { if (endDate && movingTime !== undefined) {
if (endTime === undefined) { if (endTime === undefined) {
endTime = '00:00:00'; endTime = '00:00:00';
} }
let end = getDate(endDate, endTime); let end = getDate(endDate, endTime);
let ratio = let ratio =
$gpxStatistics.global.time.moving > 0 $gpxStatistics.global.time.moving > 0
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving ? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
: 1; : 1;
let start = new Date(end.getTime() - ratio * movingTime * 1000); let start = new Date(end.getTime() - ratio * movingTime * 1000);
startDate = toCalendarDate(start); startDate = toCalendarDate(start);
startTime = start.toLocaleTimeString(); startTime = start.toLocaleTimeString();
} }
} }
function getSpeed() { function getSpeed() {
if (speed === undefined) { if (speed === undefined) {
return undefined; return undefined;
} }
let speedValue = speed; let speedValue = speed;
if ($velocityUnits === 'pace') { if ($velocityUnits === 'pace') {
speedValue = distancePerHourToSecondsPerDistance(speed); speedValue = distancePerHourToSecondsPerDistance(speed);
} }
if ($distanceUnits === 'imperial') { if ($distanceUnits === 'imperial') {
speedValue = milesToKilometers(speedValue); speedValue = milesToKilometers(speedValue);
} else if ($distanceUnits === 'nautical') { } else if ($distanceUnits === 'nautical') {
speedValue = nauticalMilesToKilometers(speedValue); speedValue = nauticalMilesToKilometers(speedValue);
} }
return speedValue; return speedValue;
} }
function updateDataFromSpeed() { function updateDataFromSpeed() {
let speedValue = getSpeed(); let speedValue = getSpeed();
if (speedValue === undefined) { if (speedValue === undefined) {
return; return;
} }
let distance = let distance =
$gpxStatistics.global.distance.moving > 0 $gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving ? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total; : $gpxStatistics.global.distance.total;
movingTime = (distance / speedValue) * 3600; movingTime = (distance / speedValue) * 3600;
updateEnd(); updateEnd();
} }
function updateDataFromTotalTime() { function updateDataFromTotalTime() {
if (movingTime === undefined) { if (movingTime === undefined) {
return; return;
} }
let distance = let distance =
$gpxStatistics.global.distance.moving > 0 $gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving ? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total; : $gpxStatistics.global.distance.total;
setSpeed(distance / (movingTime / 3600)); setSpeed(distance / (movingTime / 3600));
updateEnd(); updateEnd();
} }
$: canUpdate = $: canUpdate =
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']); $selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
</script> </script>
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"> <div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
<fieldset class="flex flex-col gap-2"> <fieldset class="flex flex-col gap-2">
<div class="flex flex-row gap-2 justify-center"> <div class="flex flex-row gap-2 justify-center">
<div class="flex flex-col gap-2 grow"> <div class="flex flex-col gap-2 grow">
<Label for="speed" class="flex flex-row"> <Label for="speed" class="flex flex-row">
<Zap size="16" class="mr-1" /> <Zap size="16" class="mr-1" />
{#if $velocityUnits === 'speed'} {#if $velocityUnits === 'speed'}
{$_('quantities.speed')} {$_('quantities.speed')}
{:else} {:else}
{$_('quantities.pace')} {$_('quantities.pace')}
{/if} {/if}
</Label> </Label>
<div class="flex flex-row gap-1 items-center"> <div class="flex flex-row gap-1 items-center">
{#if $velocityUnits === 'speed'} {#if $velocityUnits === 'speed'}
<Input <Input
id="speed" id="speed"
type="number" type="number"
step={0.01} step={0.01}
min={0.01} min={0.01}
disabled={!canUpdate} disabled={!canUpdate}
bind:value={speed} bind:value={speed}
on:change={updateDataFromSpeed} on:change={updateDataFromSpeed}
/> />
<span class="text-sm shrink-0"> <span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'} {#if $distanceUnits === 'imperial'}
{$_('units.miles_per_hour')} {$_('units.miles_per_hour')}
{:else if $distanceUnits === 'metric'} {:else if $distanceUnits === 'metric'}
{$_('units.kilometers_per_hour')} {$_('units.kilometers_per_hour')}
{:else if $distanceUnits === 'nautical'} {:else if $distanceUnits === 'nautical'}
{$_('units.knots')} {$_('units.knots')}
{/if} {/if}
</span> </span>
{:else} {:else}
<TimePicker <TimePicker
bind:value={speed} bind:value={speed}
showHours={false} showHours={false}
disabled={!canUpdate} disabled={!canUpdate}
onChange={updateDataFromSpeed} onChange={updateDataFromSpeed}
/> />
<span class="text-sm shrink-0"> <span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'} {#if $distanceUnits === 'imperial'}
{$_('units.minutes_per_mile')} {$_('units.minutes_per_mile')}
{:else if $distanceUnits === 'metric'} {:else if $distanceUnits === 'metric'}
{$_('units.minutes_per_kilometer')} {$_('units.minutes_per_kilometer')}
{:else if $distanceUnits === 'nautical'} {:else if $distanceUnits === 'nautical'}
{$_('units.minutes_per_nautical_mile')} {$_('units.minutes_per_nautical_mile')}
{/if} {/if}
</span> </span>
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 grow"> <div class="flex flex-col gap-2 grow">
<Label for="duration" class="flex flex-row"> <Label for="duration" class="flex flex-row">
<Timer size="16" class="mr-1" /> <Timer size="16" class="mr-1" />
{$_('toolbar.time.total_time')} {$_('toolbar.time.total_time')}
</Label> </Label>
<TimePicker <TimePicker
bind:value={movingTime} bind:value={movingTime}
disabled={!canUpdate} disabled={!canUpdate}
onChange={updateDataFromTotalTime} onChange={updateDataFromTotalTime}
/> />
</div> </div>
</div> </div>
<Label class="flex flex-row"> <Label class="flex flex-row">
<CirclePlay size="16" class="mr-1" /> <CirclePlay size="16" class="mr-1" />
{$_('toolbar.time.start')} {$_('toolbar.time.start')}
</Label> </Label>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<DatePicker <DatePicker
bind:value={startDate} bind:value={startDate}
disabled={!canUpdate} disabled={!canUpdate}
locale={get(locale) ?? 'en'} locale={get(locale) ?? 'en'}
placeholder={$_('toolbar.time.pick_date')} placeholder={$_('toolbar.time.pick_date')}
class="w-fit grow" class="w-fit grow"
onValueChange={async () => { onValueChange={async () => {
await tick(); await tick();
updateEnd(); updateEnd();
}} }}
/> />
<input <input
type="time" type="time"
step={1} step={1}
disabled={!canUpdate} disabled={!canUpdate}
bind:value={startTime} bind:value={startTime}
class="w-fit" class="w-fit"
on:change={updateEnd} on:change={updateEnd}
/> />
</div> </div>
<Label class="flex flex-row"> <Label class="flex flex-row">
<CircleStop size="16" class="mr-1" /> <CircleStop size="16" class="mr-1" />
{$_('toolbar.time.end')} {$_('toolbar.time.end')}
</Label> </Label>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<DatePicker <DatePicker
bind:value={endDate} bind:value={endDate}
disabled={!canUpdate} disabled={!canUpdate}
locale={get(locale) ?? 'en'} locale={get(locale) ?? 'en'}
placeholder={$_('toolbar.time.pick_date')} placeholder={$_('toolbar.time.pick_date')}
class="w-fit grow" class="w-fit grow"
onValueChange={async () => { onValueChange={async () => {
await tick(); await tick();
updateStart(); updateStart();
}} }}
/> />
<input <input
type="time" type="time"
step={1} step={1}
disabled={!canUpdate} disabled={!canUpdate}
bind:value={endTime} bind:value={endTime}
class="w-fit" class="w-fit"
on:change={updateStart} on:change={updateStart}
/> />
</div> </div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined} {#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
<div class="mt-0.5 flex flex-row gap-1 items-center hidden"> <div class="mt-0.5 flex flex-row gap-1 items-center">
<Checkbox id="artificial-time" disabled={!canUpdate} /> <Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
<Label for="artificial-time"> <Label for="artificial-time">
{$_('toolbar.time.artificial')} {$_('toolbar.time.artificial')}
</Label> </Label>
</div> </div>
{/if} {/if}
</fieldset> </fieldset>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<Button <Button
variant="outline" variant="outline"
disabled={!canUpdate} disabled={!canUpdate}
class="grow" class="grow"
on:click={() => { on:click={() => {
let effectiveSpeed = getSpeed(); let effectiveSpeed = getSpeed();
if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) { if (
return; startDate === undefined ||
} startTime === undefined ||
effectiveSpeed === undefined
) {
return;
}
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) { if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
effectiveSpeed = $gpxStatistics.global.speed.moving; effectiveSpeed = $gpxStatistics.global.speed.moving;
} }
let ratio = 1; let ratio = 1;
if ( if (
$gpxStatistics.global.speed.moving > 0 && $gpxStatistics.global.speed.moving > 0 &&
$gpxStatistics.global.speed.moving !== effectiveSpeed $gpxStatistics.global.speed.moving !== effectiveSpeed
) { ) {
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed; ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
} }
let item = $selection.getSelected()[0]; let item = $selection.getSelected()[0];
let fileId = item.getFileId(); let fileId = item.getFileId();
dbUtils.applyToFile(fileId, (file) => { dbUtils.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) { if (item instanceof ListFileItem) {
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio); if (artificial) {
} else if (item instanceof ListTrackItem) { file.createArtificialTimestamps(
file.changeTimestamps( getDate(startDate, startTime),
getDate(startDate, startTime), movingTime
effectiveSpeed, );
ratio, } else {
item.getTrackIndex() file.changeTimestamps(
); getDate(startDate, startTime),
} else if (item instanceof ListTrackSegmentItem) { effectiveSpeed,
file.changeTimestamps( ratio
getDate(startDate, startTime), );
effectiveSpeed, }
ratio, } else if (item instanceof ListTrackItem) {
item.getTrackIndex(), if (artificial) {
item.getSegmentIndex() file.createArtificialTimestamps(
); getDate(startDate, startTime),
} movingTime,
}); item.getTrackIndex()
}} );
> } else {
<CalendarClock size="16" class="mr-1" /> file.changeTimestamps(
{$_('toolbar.time.update')} getDate(startDate, startTime),
</Button> effectiveSpeed,
<Button variant="outline" on:click={setGPXData}> ratio,
<CircleX size="16" /> item.getTrackIndex()
</Button> );
</div> }
<Help link="./help/toolbar/time"> } else if (item instanceof ListTrackSegmentItem) {
{#if canUpdate} if (artificial) {
{$_('toolbar.time.help')} file.createArtificialTimestamps(
{:else} getDate(startDate, startTime),
{$_('toolbar.time.help_invalid_selection')} movingTime,
{/if} item.getTrackIndex(),
</Help> item.getSegmentIndex()
);
} else {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
ratio,
item.getTrackIndex(),
item.getSegmentIndex()
);
}
}
});
}}
>
<CalendarClock size="16" class="mr-1" />
{$_('toolbar.time.update')}
</Button>
<Button variant="outline" on:click={setGPXData}>
<CircleX size="16" />
</Button>
</div>
<Help link="./help/toolbar/time">
{#if canUpdate}
{$_('toolbar.time.help')}
{:else}
{$_('toolbar.time.help_invalid_selection')}
{/if}
</Help>
</div> </div>
<style lang="postcss"> <style lang="postcss">
div :global(input[type='time']) { div :global(input[type='time']) {
/* /*
Style copy-pasted from shadcn-svelte Input. Style copy-pasted from shadcn-svelte Input.
Needed to use native time input to avoid a bug with 2-level bind:value. Needed to use native time input to avoid a bug with 2-level bind:value.
*/ */
@apply flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; @apply flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
} }
</style> </style>