mirror of
https://github.com/catdevnull/preciazo.git
synced 2024-11-22 14:16:19 +00:00
usar maplibre
This commit is contained in:
parent
8d33203c95
commit
544b0471b9
6 changed files with 271 additions and 79 deletions
BIN
sepa/bun.lockb
BIN
sepa/bun.lockb
Binary file not shown.
|
@ -3,7 +3,8 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.1.7",
|
"@types/bun": "^1.1.11",
|
||||||
|
"bun-types": "^1.1.30",
|
||||||
"@types/papaparse": "^5.3.14",
|
"@types/papaparse": "^5.3.14",
|
||||||
"drizzle-kit": "^0.24.2"
|
"drizzle-kit": "^0.24.2"
|
||||||
},
|
},
|
||||||
|
|
|
@ -51,12 +51,14 @@
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
|
"geojson": "^0.5.0",
|
||||||
"layerchart": "^0.44.0",
|
"layerchart": "^0.44.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
"lucide-svelte": "^0.441.0",
|
"lucide-svelte": "^0.441.0",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
|
"svelte-maplibre": "^0.9.14",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwind-variants": "^0.2.1"
|
"tailwind-variants": "^0.2.1"
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,10 @@ const buttonVariants = tv({
|
||||||
size: {
|
size: {
|
||||||
default: 'h-10 px-4 py-2',
|
default: 'h-10 px-4 py-2',
|
||||||
sm: 'h-9 rounded-md px-3',
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
xs: 'h-8 rounded-md px-2',
|
||||||
lg: 'h-11 rounded-md px-8',
|
lg: 'h-11 rounded-md px-8',
|
||||||
icon: 'h-10 w-10'
|
icon: 'h-10 w-10',
|
||||||
|
icon_sm: 'h-8 w-8'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|
|
@ -29,6 +29,13 @@ export const pesosFormatter = new Intl.NumberFormat('es-AR', {
|
||||||
currency: 'ARS'
|
currency: 'ARS'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const dateFormatter = Intl.DateTimeFormat('es-AR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
weekday: 'long'
|
||||||
|
});
|
||||||
|
|
||||||
export function parseMarcas(marcas: readonly string[]) {
|
export function parseMarcas(marcas: readonly string[]) {
|
||||||
const x = marcas
|
const x = marcas
|
||||||
.map((m) => m.trim().replaceAll(/['`´]/g, ''))
|
.map((m) => m.trim().replaceAll(/['`´]/g, ''))
|
||||||
|
|
|
@ -1,15 +1,77 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { ArrowLeft } from 'lucide-svelte';
|
import { ArrowLeft, ArrowRight, MapPin } from 'lucide-svelte';
|
||||||
import Map from '$lib/components/Map.svelte';
|
|
||||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { generateGoogleMapsLink, pesosFormatter, processBanderaNombre } from '$lib/sepa-utils';
|
import {
|
||||||
|
dateFormatter,
|
||||||
|
generateGoogleMapsLink,
|
||||||
|
pesosFormatter,
|
||||||
|
processBanderaNombre
|
||||||
|
} from '$lib/sepa-utils';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import {
|
||||||
|
DefaultMarker,
|
||||||
|
MapLibre,
|
||||||
|
Popup,
|
||||||
|
GeoJSON,
|
||||||
|
CircleLayer,
|
||||||
|
SymbolLayer,
|
||||||
|
HeatmapLayer
|
||||||
|
} from 'svelte-maplibre';
|
||||||
|
import style from '$lib/components/map_style.json';
|
||||||
|
import type { GeoJSON as GeoJSONType } from 'geojson';
|
||||||
|
import type { DataDrivenPropertyValueSpecification } from 'maplibre-gl';
|
||||||
|
import Button from '$lib/components/ui/button/button.svelte';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
|
$: id_producto = $page.params.id;
|
||||||
const query = $page.url.searchParams.get('query');
|
const query = $page.url.searchParams.get('query');
|
||||||
|
|
||||||
|
function generateGeoJSON(precios: (typeof data)['precios']): GeoJSONType {
|
||||||
|
const prices = data.precios.map((p) => p.productos_precio_lista);
|
||||||
|
const sortedPrices = prices.sort((a, b) => a - b);
|
||||||
|
const q1Index = Math.floor(sortedPrices.length * 0.1);
|
||||||
|
const q3Index = Math.floor(sortedPrices.length * 0.9);
|
||||||
|
const iqr = sortedPrices[q3Index] - sortedPrices[q1Index];
|
||||||
|
const lowerBound = sortedPrices[q1Index] - 1.5 * iqr;
|
||||||
|
const upperBound = sortedPrices[q3Index] + 1.5 * iqr;
|
||||||
|
const filteredPrices = sortedPrices.filter((p) => p >= lowerBound && p <= upperBound);
|
||||||
|
const min = Math.min(...filteredPrices);
|
||||||
|
const max = Math.max(...filteredPrices);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: data.precios.map((precio) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [precio.sucursales_longitud, precio.sucursales_latitud]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: Math.random(),
|
||||||
|
id_comercio: precio.id_comercio,
|
||||||
|
id_sucursal: precio.id_sucursal,
|
||||||
|
precio: precio.productos_precio_lista,
|
||||||
|
nombre: precio.sucursales_nombre,
|
||||||
|
descripcion: precio.productos_descripcion,
|
||||||
|
direccion: `${precio.sucursales_calle} ${precio.sucursales_numero ?? ''}`,
|
||||||
|
comercio: processBanderaNombre(precio),
|
||||||
|
fecha: precio.dataset_date
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$: geoJSON = generateGeoJSON(data.precios);
|
||||||
|
|
||||||
|
function hoverStateFilter(
|
||||||
|
offValue: number,
|
||||||
|
onValue: number
|
||||||
|
): DataDrivenPropertyValueSpecification<number> {
|
||||||
|
return ['case', ['boolean', ['feature-state', 'hover'], false], onValue, offValue];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
@ -34,84 +96,202 @@
|
||||||
<Badge variant="outline">EAN {data.id_producto}</Badge>
|
<Badge variant="outline">EAN {data.id_producto}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<MapLibre
|
||||||
|
style={style as any}
|
||||||
|
class="relative h-full max-h-full min-h-[50vh] w-full flex-1"
|
||||||
|
standardControls
|
||||||
|
zoom={9}
|
||||||
|
center={[-58.381944444444, -34.599722222222]}
|
||||||
|
>
|
||||||
|
<!-- cluster={{
|
||||||
|
radius: 50,
|
||||||
|
// maxZoom: 14,
|
||||||
|
maxZoom: 14,
|
||||||
|
properties: {
|
||||||
|
total_precio: ['+', ['get', 'precio']],
|
||||||
|
precio_promedio: [
|
||||||
|
'number',
|
||||||
|
['/', ['+', ['number', ['get', 'precio']]], ['get', 'point_count']]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}} -->
|
||||||
|
<GeoJSON id="precios" data={geoJSON}>
|
||||||
|
<!-- <HeatmapLayer
|
||||||
|
paint={{
|
||||||
|
// Increase the heatmap weight based on price magnitude
|
||||||
|
'heatmap-weight': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['get', 'precio'], // Get precio from properties
|
||||||
|
Math.min(...data.precios.map((p) => p.productos_precio_lista)), // Start at 0 weight for minimum price
|
||||||
|
0,
|
||||||
|
Math.max(...data.precios.map((p) => p.productos_precio_lista)), // Adjust this max value based on your price range
|
||||||
|
1
|
||||||
|
],
|
||||||
|
// Increase the heatmap intensity by zoom level
|
||||||
|
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 9, 3],
|
||||||
|
// Color ramp for heatmap. Domain is 0 (low) to 1 (high).
|
||||||
|
'heatmap-color': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['heatmap-density'],
|
||||||
|
0,
|
||||||
|
'rgba(0, 255, 0, 0)',
|
||||||
|
// 0.2,
|
||||||
|
// 'rgb(0, 204, 255)',
|
||||||
|
// 0.4,
|
||||||
|
// 'rgb(128, 255, 128)',
|
||||||
|
// 0.6,
|
||||||
|
// 'rgb(255, 255, 102)',
|
||||||
|
// 0.8,
|
||||||
|
// 'rgb(255, 128, 0)',
|
||||||
|
0.9,
|
||||||
|
'rgb(100, 255, 0)',
|
||||||
|
1,
|
||||||
|
'rgb(255, 0, 0)'
|
||||||
|
],
|
||||||
|
// Adjust the heatmap radius by zoom level
|
||||||
|
'heatmap-radius': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['get', 'precio'],
|
||||||
|
Math.min(...data.precios.map((p) => p.productos_precio_lista)),
|
||||||
|
2,
|
||||||
|
Math.max(...data.precios.map((p) => p.productos_precio_lista)),
|
||||||
|
10
|
||||||
|
],
|
||||||
|
'heatmap-opacity': 0.8
|
||||||
|
}}
|
||||||
|
/> -->
|
||||||
|
|
||||||
<Map
|
<!-- <CircleLayer
|
||||||
mapMap={(map, L) => {
|
id="cluster_circles"
|
||||||
// var markers = L.MarkerClusterGroup();
|
applyToClusters
|
||||||
const myRenderer = L.canvas({ padding: 0.5 });
|
hoverCursor="pointer"
|
||||||
const prices = data.precios.map((p) => p.productos_precio_lista);
|
paint={{
|
||||||
const sortedPrices = prices.sort((a, b) => a - b);
|
'circle-color': [
|
||||||
const q1Index = Math.floor(sortedPrices.length * 0.1);
|
'step',
|
||||||
const q3Index = Math.floor(sortedPrices.length * 0.9);
|
['get', 'total_precio'],
|
||||||
const iqr = sortedPrices[q3Index] - sortedPrices[q1Index];
|
'#51bbd6',
|
||||||
const lowerBound = sortedPrices[q1Index] - 1.5 * iqr;
|
10,
|
||||||
const upperBound = sortedPrices[q3Index] + 1.5 * iqr;
|
'#f1f075',
|
||||||
const filteredPrices = sortedPrices.filter((p) => p >= lowerBound && p <= upperBound);
|
30,
|
||||||
const min = Math.min(...filteredPrices);
|
'#f28cb1'
|
||||||
const max = Math.max(...filteredPrices);
|
],
|
||||||
console.log({ min, max, outliers: prices.length - filteredPrices.length });
|
// 'circle-radius': ['step', ['get', 'point_count'], 15, 10, 20, 30, 25],
|
||||||
|
'circle-radius': 5,
|
||||||
|
'circle-stroke-color': '#fff',
|
||||||
|
'circle-stroke-width': 1,
|
||||||
|
'circle-stroke-opacity': hoverStateFilter(0, 1)
|
||||||
|
}}
|
||||||
|
manageHoverState
|
||||||
|
on:click={(e) => {
|
||||||
|
console.log(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- <Popup openOn="click" closeOnClickInside let:data>
|
||||||
|
{#if data?.properties}
|
||||||
|
<div class="p-2">
|
||||||
|
<p class="font-bold">Grupo de {data.properties.point_count} precios</p>
|
||||||
|
<p>Precio promedio: ${data.properties.precio_promedio.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Popup> --
|
||||||
|
</CircleLayer>
|
||||||
|
|
||||||
// For each row in data, create a marker and add it to the map
|
<SymbolLayer
|
||||||
// For each row, columns `Latitude`, `Longitude`, and `Title` are required
|
id="cluster_labels"
|
||||||
for (const precio of data.precios) {
|
interactive={false}
|
||||||
const normalizedPrice = (precio.productos_precio_lista - min) / (max - min);
|
applyToClusters
|
||||||
// Safari doesn't support color-mix, so we'll use a fallback
|
layout={{
|
||||||
const color = getSafeColor(normalizedPrice);
|
'text-field': [
|
||||||
|
'format',
|
||||||
|
['get', 'point_count_abbreviated'],
|
||||||
|
{},
|
||||||
|
'\n$',
|
||||||
|
{},
|
||||||
|
['number-format', ['get', 'total_precio'], { 'max-fraction-digits': 2 }],
|
||||||
|
{ 'font-scale': 0.8 }
|
||||||
|
],
|
||||||
|
'text-size': 12,
|
||||||
|
'text-offset': [0, -0.1]
|
||||||
|
}}
|
||||||
|
/>-->
|
||||||
|
|
||||||
const createElement = () => {
|
<CircleLayer
|
||||||
const div = document.createElement('div');
|
id="precio_circle"
|
||||||
|
applyToClusters={false}
|
||||||
|
hoverCursor="pointer"
|
||||||
|
paint={{
|
||||||
|
'circle-color': [
|
||||||
|
'interpolate',
|
||||||
|
['linear'],
|
||||||
|
['get', 'precio'],
|
||||||
|
Math.min(...data.precios.map((p) => p.productos_precio_lista)),
|
||||||
|
'rgba(0,255,0,0)',
|
||||||
|
Math.max(...data.precios.map((p) => p.productos_precio_lista)),
|
||||||
|
'rgba(255,0,0,1)'
|
||||||
|
],
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 3, 4, 10, 6],
|
||||||
|
'circle-stroke-width': 1,
|
||||||
|
'circle-stroke-color': '#fff'
|
||||||
|
// 'circle-stroke-opacity': hoverStateFilter(0, 1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup openOn="click" closeOnClickInside let:data>
|
||||||
|
{#if data?.properties}
|
||||||
|
<div class="flex flex-col gap-2 px-3 py-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs uppercase leading-none text-neutral-500">
|
||||||
|
{dateFormatter.format(new Date(data.properties.fecha))}
|
||||||
|
</span>
|
||||||
|
<span class="text-xl font-bold leading-none">
|
||||||
|
{pesosFormatter.format(data.properties.precio)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
[
|
<div class="flex gap-2">
|
||||||
`fecha del precio: ${precio.dataset_date}`,
|
<div class="flex flex-col leading-none">
|
||||||
`precio: ${pesosFormatter.format(precio.productos_precio_lista)}`,
|
<span class="font-medium">{data.properties.comercio}</span>
|
||||||
`comercio: ${processBanderaNombre(precio)} (${precio.comercio_razon_social} CUIT ${precio.comercio_cuit})`,
|
<span class="text-sm">{data.properties.direccion}</span>
|
||||||
`sucursal: ${precio.sucursales_nombre}`,
|
</div>
|
||||||
`dirección: ${precio.sucursales_calle} ${precio.sucursales_numero}`,
|
|
||||||
() => {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
if (precio.sucursales_calle) {
|
|
||||||
a.href = generateGoogleMapsLink({
|
|
||||||
sucursales_calle: precio.sucursales_calle,
|
|
||||||
sucursales_numero: precio.sucursales_numero
|
|
||||||
});
|
|
||||||
}
|
|
||||||
a.target = '_blank';
|
|
||||||
a.append('ver en Google Maps');
|
|
||||||
return a;
|
|
||||||
},
|
|
||||||
`descripcion del producto segun el comercio: ${precio.productos_descripcion}`,
|
|
||||||
() => {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = `/id_producto/${data.id_producto}/sucursal/${precio.id_comercio}/${precio.id_sucursal}`;
|
|
||||||
a.append('ver precios historicos');
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
].forEach((el) => {
|
|
||||||
div.append(typeof el === 'function' ? el() : el);
|
|
||||||
div.append(document.createElement('br'));
|
|
||||||
});
|
|
||||||
return div;
|
|
||||||
};
|
|
||||||
|
|
||||||
var marker = L.circleMarker([precio.sucursales_latitud, precio.sucursales_longitud], {
|
<Button
|
||||||
opacity: 1,
|
href={generateGoogleMapsLink({
|
||||||
renderer: myRenderer,
|
sucursales_calle: data.properties.sucursales_calle,
|
||||||
color,
|
sucursales_numero: data.properties.sucursales_numero
|
||||||
radius: 5
|
})}
|
||||||
})
|
target="_blank"
|
||||||
.bindPopup(createElement)
|
variant="outline"
|
||||||
.addTo(map);
|
size="icon_sm"
|
||||||
marker.on('click', function (this: L.CircleMarker) {
|
class="inline-flex items-center gap-1"
|
||||||
this.openPopup();
|
>
|
||||||
});
|
<MapPin class="size-4" />
|
||||||
}
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
// Helper function to get a color that works in Safari
|
<div>
|
||||||
function getSafeColor(normalizedPrice: number) {
|
<Button
|
||||||
const r = Math.round(255 * normalizedPrice);
|
variant="default"
|
||||||
const g = Math.round(255 * (1 - normalizedPrice));
|
size="xs"
|
||||||
return `rgb(${r}, ${g}, 0)`;
|
href={`/id_producto/${id_producto}/sucursal/${data.properties.id_comercio}/${data.properties.id_sucursal}`}
|
||||||
}
|
class="group"
|
||||||
}}
|
>
|
||||||
/>
|
Precios históricos
|
||||||
|
<ArrowRight class="mx-1 size-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Popup>
|
||||||
|
</CircleLayer>
|
||||||
|
</GeoJSON>
|
||||||
|
</MapLibre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.maplibregl-popup-content) {
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
Loading…
Reference in a new issue