diff --git a/anemui-core/assets/images/banner_logos.png b/anemui-core/assets/images/banner_logos.png new file mode 100644 index 0000000..2824b7d Binary files /dev/null and b/anemui-core/assets/images/banner_logos.png differ diff --git a/anemui-core/css/graphcontainer.scss b/anemui-core/css/graphcontainer.scss index 6ebcb06..5285e7a 100644 --- a/anemui-core/css/graphcontainer.scss +++ b/anemui-core/css/graphcontainer.scss @@ -1,18 +1,37 @@ div.GraphContainer { @include bgFrame(12px); - - position: absolute; + + position: fixed; left: 50%; - top: 30%; - transform: translate(-50%, -30%); + top: 50%; + transform: translate(-50%, -50%); padding: 0.8em 1.5em; - background-color: rgba(255, 255, 255, 1); + background-color: rgba(255, 255, 255, 0.98); background: white; color: #333; box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4); z-index: 2000; width: fit-content; - position: relative; + max-width: 90vw; + max-height: 85vh; + overflow: auto; + cursor: move; + + // Responsive para pantallas pequeñas + @media (max-width: 768px) { + left: 5%; + top: 10%; + transform: none; + max-width: 90vw; + padding: 0.5em 1em; + } + + @media (max-width: 480px) { + left: 2%; + top: 5%; + max-width: 96vw; + padding: 0.3em 0.5em; + } a.popup-close-button { position: absolute; @@ -67,7 +86,8 @@ div.GraphContainer { .dygraph-legend { text-align: center; - width: 1024px; + width: 100% !important; + max-width: 100%; background-color: transparent !important; left: 0px !important; } diff --git a/anemui-core/css/topbar.scss b/anemui-core/css/topbar.scss index 649ebbb..df7bf5a 100644 --- a/anemui-core/css/topbar.scss +++ b/anemui-core/css/topbar.scss @@ -534,6 +534,7 @@ input.selection-param-input { .menu-checkbox { padding: 0; + margin-left: 15px; display: flex; align-items: center; gap: 8px; diff --git a/anemui-core/env/env.js b/anemui-core/env/env.js index 6a7918f..9ed210a 100644 --- a/anemui-core/env/env.js +++ b/anemui-core/env/env.js @@ -5,7 +5,8 @@ module.exports={ isTileDebugEnabled:false, mapboxMapID:'b0rja/clo334xwq00jn01r28vwe1h6k', mapboxAccessToken:'pk.eyJ1IjoiYjByamEiLCJhIjoiY2s5NjhvYjlkMGRsczNlbDQ3YXhvZTBvZyJ9.S3-_Wjl7BXcCLDOXNSbr_A', - logo:'logo_aemet.png', + // logo:'logo_aemet.png', + logo:'banner_logos.png', initialZoom:6, ncSignif:7, dataSource: 'nc' // 'nc' or 'zarr' diff --git a/anemui-core/package.json b/anemui-core/package.json index e52f743..9ceff35 100644 --- a/anemui-core/package.json +++ b/anemui-core/package.json @@ -1,6 +1,6 @@ { "name": "@lcsc/anemui-core", - "version": "0.2.2-SNAPSHOT", + "version": "0.2.3-SNAPSHOT", "description": "Climatic Services Viewer Core", "main": "./src/index.ts", "types": "./src/index.ts", @@ -82,6 +82,6 @@ "zarr": "^0.6.3" }, "devDependencies": { - "@lcsc/anemui-test": ">=0.1.0-20250826" + "@lcsc/anemui-test": ">=0.2.3-202512180942" } } diff --git a/anemui-core/src/BaseApp.ts b/anemui-core/src/BaseApp.ts index 0be68ad..659a53b 100644 --- a/anemui-core/src/BaseApp.ts +++ b/anemui-core/src/BaseApp.ts @@ -578,7 +578,7 @@ export abstract class BaseApp implements CsMapListener, MenuBarListener, DateFra //TODO on change let timeSpan = this.getTimeSpan(_timesJs.times[varId]) // let timeIndex = typeof _timesJs.times[varId] === 'string'? 0:_timesJs.times[varId].length - 1 - let timeIndex = timeSpan == CsTimeSpan.Year? 0:_timesJs.times[varId].length - 1 + let timeIndex = timeSpan == CsTimeSpan.Year? 0 : _timesJs.times[varId].length - 1 let legendTitle: string; if (_timesJs.legendTitle[varId] != undefined) { legendTitle = _timesJs.legendTitle[varId] @@ -616,6 +616,7 @@ export abstract class BaseApp implements CsMapListener, MenuBarListener, DateFra public getTimeSpan (time:string[]): CsTimeSpan { if(typeof time === 'string') return CsTimeSpan.Year let number + let yearCount = 1 if (time.length<=12) number = time.length else { const result = time.reduce((acc, curr) => { @@ -625,15 +626,25 @@ export abstract class BaseApp implements CsMapListener, MenuBarListener, DateFra } return acc; }, []); + yearCount = result.length number = time.length / result.length; } + // Caso especial: si hay exactamente 365 o 366 elementos totales, es día juliano + // independientemente de si las fechas cruzan dos años + if (time.length === 365 || time.length === 366) { + return CsTimeSpan.Day; + } + switch (number) { - case 1: - return CsTimeSpan.Year; + case 1: + return yearCount > 1 ? CsTimeSpan.YearSeries : CsTimeSpan.Year; case 4: return CsTimeSpan.Season; case 12: return CsTimeSpan.Month; + case 365: + case 366: + return CsTimeSpan.Day; default: return CsTimeSpan.Date; } @@ -697,6 +708,7 @@ export abstract class BaseApp implements CsMapListener, MenuBarListener, DateFra // Continue with update even if there's an error if (!dateChanged) this.dateSelectorFrame.update(); this.paletteFrame.update(); + this.layerFrame.update(); this.changeUrl(); } } @@ -780,7 +792,7 @@ export abstract class BaseApp implements CsMapListener, MenuBarListener, DateFra */ protected isClimatologyCyclicMode(): boolean { return this.state.climatology && - (this.state.timeSpan === CsTimeSpan.Month || this.state.timeSpan === CsTimeSpan.Season); + (this.state.timeSpan === CsTimeSpan.Day || this.state.timeSpan === CsTimeSpan.Month || this.state.timeSpan === CsTimeSpan.Season); } public dateDateBack(): void { diff --git a/anemui-core/src/Env.ts b/anemui-core/src/Env.ts index 0156c79..afaa701 100644 --- a/anemui-core/src/Env.ts +++ b/anemui-core/src/Env.ts @@ -48,6 +48,7 @@ export const hasClimatology:boolean = typeof ENV.hasClimatology !== 'undefined'? export const logoStyle:string = typeof ENV.logoStyle !== 'undefined'? ENV.logoStyle:'longLogo'; export const maxPaletteValue = ENV.maxPaletteValue !== 'undefined'? ENV.maxPaletteValue:1000; export const maxPaletteSteps = ENV.maxPaletteSteps !== 'undefined'? ENV.maxPaletteSteps:10; +export const globalMap = ENV.globalMap !== 'undefined'? ENV.globalMap:false; // Factory Method Pattern // true = usar factory method (permite override en subclases) diff --git a/anemui-core/src/LayerManager.ts b/anemui-core/src/LayerManager.ts index 42086d6..ac8eb40 100644 --- a/anemui-core/src/LayerManager.ts +++ b/anemui-core/src/LayerManager.ts @@ -69,6 +69,7 @@ export class LayerManager { private topLayerVector:Layer; private topLayerImage:Image; protected uncertaintyLayer: (Image | WebGLTile)[]; + private uncertaintyLayerVisible: boolean = false; private constructor() { // CAPAS BASE @@ -98,6 +99,7 @@ export class LayerManager { this.topSelected="Límites estatales (mapbox)"; this.uncertaintyLayer = []; + this.uncertaintyLayerVisible = false; } // Base Layer diff --git a/anemui-core/src/OpenLayersMap.ts b/anemui-core/src/OpenLayersMap.ts index 5c5d251..f0f90b1 100644 --- a/anemui-core/src/OpenLayersMap.ts +++ b/anemui-core/src/OpenLayersMap.ts @@ -12,7 +12,7 @@ import { PaletteManager } from "./PaletteManager"; import { isTileDebugEnabled, isWmsEnabled, olProjection, initialZoom, computedDataTilesLayer } from "./Env"; import proj4 from 'proj4'; import { register } from 'ol/proj/proj4.js'; -import { buildImages, downloadXYChunk, CsvDownloadDone, downloadXYbyRegion, getPortionForPoint, downloadHistoricalDataForPercentile, calcPixelIndex, downloadTArrayChunked } from "./data/ChunkDownloader"; +import { buildImages, downloadXYChunk, CsvDownloadDone, downloadXYbyRegion, getPortionForPoint, downloadHistoricalDataForPercentile, calcPixelIndex, downloadTArrayChunked, downloadXYbyRegionMultiPortion } from "./data/ChunkDownloader"; import VectorSource from "ol/source/Vector"; import VectorLayer from "ol/layer/Vector"; import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style.js'; @@ -838,31 +838,52 @@ public async buildDataTilesLayers(state: CsViewerData, timesJs: CsTimesJsData): } } - async initializeFeatureLayer(time: string, timeIndex: number, folder: string, varName: string): Promise { +async initializeFeatureLayer(time: string, timeIndex: number, folder: string, varName: string): Promise { return new Promise((resolve, reject) => { - let openSt: CsvDownloadDone = (data: any, filename: string, type: string) => { - if (this.featureLayer) { - this.featureLayer.indexData = data; - if (this.featureLayer.show) { - this.featureLayer.show(this.renderers.indexOf(this.lastSupport)); - } - resolve(); + let openSt: CsvDownloadDone = (data: any, filename: string, type: string) => { + if (this.featureLayer) { + this.featureLayer.indexData = data; + if (this.featureLayer.show) { + this.featureLayer.show(this.renderers.indexOf(this.lastSupport)); + } + resolve(); + } else { + console.error("featureLayer is undefined in initializeFeatureLayer callback"); + reject("featureLayer is undefined"); + } + }; + + // Detectar si hay múltiples porciones + const timesJs = this.parent.getParent().getTimesJs(); + const portions = timesJs.portions[varName] || []; + + + // Si hay múltiples porciones, usar la función que combina + if (portions.length > 1) { + + // Importar la función desde ChunkDownloader + const { downloadXYbyRegionMultiPortion } = require('./data/ChunkDownloader'); + + downloadXYbyRegionMultiPortion( + time, + timeIndex, + folder, + varName, + portions, + openSt + ); } else { - console.error("featureLayer is undefined in initializeFeatureLayer callback"); - reject("featureLayer is undefined"); + + if (computedDataTilesLayer) { + // Build calculated Layer + this.computeFeatureLayerData(time, folder, varName, openSt); + } else { + // Download and build new data layers + downloadXYbyRegion(time, timeIndex, folder, varName, openSt); + } } - }; - - if (computedDataTilesLayer) { - // Build calculated Layer - this.computeFeatureLayerData(time, folder, varName, openSt); - } else { - // Download and build new data layers - downloadXYbyRegion(time, timeIndex, folder, varName, openSt); - } - }); - } +} private setupInteractions(): void { if (this.selectInteraction) { @@ -1194,44 +1215,73 @@ export class CsOpenLayerGeoJsonLayer extends CsGeoJsonLayer { return new Style({ image: imgStation, }) } - public setFeatureStyle(state: CsViewerData, feature: Feature, timesJs: CsTimesJsData): Style { +public setFeatureStyle(state: CsViewerData, feature: Feature, timesJs: CsTimesJsData): Style { let min: number = Number.MAX_VALUE; let max: number = Number.MIN_VALUE; Object.values(this.indexData).forEach((value) => { - if (!isNaN(value)) { - min = Math.min(min, value); - max = Math.max(max, value); - } + if (!isNaN(value)) { + min = Math.min(min, value); + max = Math.max(max, value); + } }); let color: string = '#fff'; let id = feature.getProperties()['id']; let id_ant = feature.getProperties()['id_ant']; + let name = feature.getProperties()['name']; let ptr = PaletteManager.getInstance().getPainter(); - // Use exact match instead of includes to avoid matching '1' with '10', '11', etc. - // Try with new id first, fallback to old id_ant if not found - let dataValue = this.indexData[id]; - if (dataValue === undefined && id_ant !== undefined) { - dataValue = this.indexData[id_ant]; + if (id === null || id === undefined) { + const isHovered = feature.get('hover'); + if (isHovered) this.map.getTargetElement().style.cursor = 'pointer'; + else this.map.getTargetElement().style.cursor = ''; + + return new Style({ + fill: new Fill({ color: '#ffffff00' }), // Transparente + stroke: new Stroke({ + color: '#999', + }), + }); + } + + let dataValue = undefined; + + if (this.indexData[id] !== undefined) { + dataValue = this.indexData[id]; + } + else if (id_ant && this.indexData[id_ant] !== undefined) { + dataValue = this.indexData[id_ant]; + } + else if (id.length === 1 && this.indexData['0' + id] !== undefined) { + dataValue = this.indexData['0' + id]; + } + else if (id.startsWith('0') && this.indexData[id.substring(1)] !== undefined) { + dataValue = this.indexData[id.substring(1)]; + } + else if (id_ant && id_ant.length >= 2) { + const shortCode = id_ant.substring(0, 2); + if (this.indexData[shortCode] !== undefined) { + dataValue = this.indexData[shortCode]; + } } - if (dataValue !== undefined) { - color = ptr.getColorString(dataValue, min, max); + if (dataValue !== undefined && !isNaN(dataValue)) { + color = ptr.getColorString(dataValue, min, max); } const isHovered = feature.get('hover'); if (isHovered) this.map.getTargetElement().style.cursor = 'pointer'; else this.map.getTargetElement().style.cursor = ''; + return new Style({ - fill: new Fill({ color: isHovered ? this.highLightColor(color, 0.2) : color }), - stroke: new Stroke({ - color: '#999', - }), + fill: new Fill({ color: isHovered ? this.highLightColor(color, 0.2) : color }), + stroke: new Stroke({ + color: '#999', + }), }); - } +} private intersectsExtent(featureExtent: number[], viewExtent: number[]): boolean { return !( @@ -1242,43 +1292,86 @@ export class CsOpenLayerGeoJsonLayer extends CsGeoJsonLayer { ); } - public showFeatureValue(data: any, feature: any, pixel: any, pos: CsLatLong): void { +public showFeatureValue(data: any, feature: any, pixel: any, pos: CsLatLong): void { let state: CsViewerData = this.csMap.getParent().getParent().getState(); let timesJs = this.csMap.getParent().getParent().getTimesJs(); - let value: string; + if (feature) { - if (state.support == this.csMap.renderers[0]) feature.setStyle(this.setStationStyle(state, feature, timesJs)) - else feature.setStyle(this.setFeatureStyle(state, feature, timesJs)); - this.csMap.popupContent.style.left = pixel[0] + 'px'; - this.csMap.popupContent.style.top = pixel[1] + 'px'; - this.csMap.popup.hidden = false - if (feature !== this.currentFeature) { - let id = feature.getProperties()['id'] - let id_ant = feature.getProperties()['id_ant'] - // Use exact match instead of includes to avoid matching '1' with '10', '11', etc. - // Try with new id first, fallback to old id_ant if not found - value = data[id]; - if (value === undefined && id_ant !== undefined) { - value = data[id_ant]; + if (state.support == this.csMap.renderers[0]) { + feature.setStyle(this.setStationStyle(state, feature, timesJs)); + } else { + feature.setStyle(this.setFeatureStyle(state, feature, timesJs)); + } + + this.csMap.popupContent.style.left = pixel[0] + 'px'; + this.csMap.popupContent.style.top = pixel[1] + 'px'; + this.csMap.popup.hidden = false; + + if (feature !== this.currentFeature) { + let id = feature.getProperties()['id']; + let id_ant = feature.getProperties()['id_ant']; + let name = feature.getProperties()['name']; + + let value: any = undefined; + + if (data[id] !== undefined) { + value = data[id]; + } + else if (id_ant && data[id_ant] !== undefined) { + value = data[id_ant]; + } + else if (id && id.length === 1 && data['0' + id] !== undefined) { + value = data['0' + id]; + } + else if (id && id.startsWith('0') && data[id.substring(1)] !== undefined) { + value = data[id.substring(1)]; + } + else if (id_ant && id_ant.length >= 2) { + const shortCode = id_ant.substring(0, 2); + if (data[shortCode] !== undefined) { + value = data[shortCode]; + } + } + + if (value === undefined) { + value = 'N/A'; + } else if (isNaN(parseFloat(value))) { + value = 'N/A'; + } else { + value = parseFloat(value); + } + + this.csMap.popupContent.style.visibility = 'visible'; + this.csMap.popupContent.innerHTML = this.formatFeaturePopupValue(name, id, value); + this.csMap.value.setPosition(proj4('EPSG:4326', olProjection, [pos.lng, pos.lat])); } - this.csMap.popupContent.style.visibility = 'visible'; - this.csMap.popupContent.innerHTML = this.formatFeaturePopupValue(feature.get('name'), id, parseFloat(value)); - this.csMap.value.setPosition(proj4('EPSG:4326', olProjection, [pos.lng, pos.lat])) - } } else { - this.csMap.popupContent.style.visibility = 'hidden'; - this.csMap.popup.hidden = true + this.csMap.popupContent.style.visibility = 'hidden'; + this.csMap.popup.hidden = true; } + if (this.currentFeature instanceof Feature) { - if (state.support == this.csMap.renderers[0]) this.currentFeature.setStyle(this.setStationStyle(state, this.currentFeature, timesJs)) - else this.currentFeature.setStyle(this.setFeatureStyle(state, this.currentFeature, timesJs)); + if (state.support == this.csMap.renderers[0]) { + this.currentFeature.setStyle(this.setStationStyle(state, this.currentFeature, timesJs)); + } else { + this.currentFeature.setStyle(this.setFeatureStyle(state, this.currentFeature, timesJs)); + } } this.currentFeature = feature; - }; +} + +public formatFeaturePopupValue(featureName: string, featureId: any, value: number | string): string { + if (value === 'N/A' || value === undefined || value === null) { + return `${featureName}: Sin datos`; + } + + if (typeof value === 'number') { + return this.csMap.getParent().getParent().formatPopupValue(featureName + ': ', featureId, '', value); + } + + return `${featureName}: ${value}`; +} - public formatFeaturePopupValue(featureName: string, featureId: any, value: number): string { - return this.csMap.getParent().getParent().formatPopupValue(featureName + ': ', featureId, '', value); - } public highLightColor(hex: string, lum: number): string { hex = String(hex).replace(/[^0-9a-f]/gi, ''); @@ -1303,4 +1396,4 @@ export class CsOpenLayerGeoJsonLayer extends CsGeoJsonLayer { // loadThreshold?: number; // source: VectorSource; // geoJSONData: any; // Your GeoJSON data -// } +// } \ No newline at end of file diff --git a/anemui-core/src/PaletteManager.ts b/anemui-core/src/PaletteManager.ts index e7be8d8..a45dddf 100644 --- a/anemui-core/src/PaletteManager.ts +++ b/anemui-core/src/PaletteManager.ts @@ -177,78 +177,103 @@ export class CategoryRangePainter implements Painter { this.ranges = ranges; } - public async paintValues(floatArray: number[], width: number, height: number, minArray: number, maxArray: number, pxTransparent: number, uncertaintyLayer: boolean): Promise { - // Validar y asegurar que width y height sean enteros positivos válidos - width = Math.max(1, Math.floor(width)); - height = Math.max(1, Math.floor(height)); - - if (!isFinite(width) || !isFinite(height)) { - console.error('Invalid canvas dimensions:', width, height); - width = 1; - height = 1; - } - - let canvas: HTMLCanvasElement = document.createElement('canvas'); - let context: CanvasRenderingContext2D = canvas.getContext('2d'); - canvas.width = width; - canvas.height = height; - let imgData: ImageData = context.getImageData(0, 0, width, height); - let gradient = PaletteManager.getInstance().updatePalete32(uncertaintyLayer); - - // VERIFICACIÓN CRÍTICA - if (gradient.length !== this.ranges.length) { - console.error('❌ CRITICAL: Gradient colors (' + gradient.length + ') != Ranges (' + this.ranges.length + ')'); - } - - const bitmap: Uint32Array = new Uint32Array(imgData.data.buffer); - - for (let y: number = 0; y < height; y++) { - for (let x: number = 0; x < width; x++) { - let ncIndex: number = x + y * width; - let value: number = floatArray[ncIndex]; - let pxIndex: number = x + ((height - 1) - y) * width; - - if (!isNaN(value) && isFinite(value)) { +public async paintValues(floatArray: number[], width: number, height: number, minArray: number, maxArray: number, pxTransparent: number, uncertaintyLayer: boolean): Promise { + // Validar dimensiones + width = Math.max(1, Math.floor(width)); + height = Math.max(1, Math.floor(height)); + + if (!isFinite(width) || !isFinite(height)) { + console.error('Invalid canvas dimensions:', width, height); + width = 1; + height = 1; + } + + let canvas: HTMLCanvasElement = document.createElement('canvas'); + let context: CanvasRenderingContext2D = canvas.getContext('2d'); + canvas.width = width; + canvas.height = height; + let imgData: ImageData = context.getImageData(0, 0, width, height); + + let gradient = PaletteManager.getInstance().updatePalete32(uncertaintyLayer); + const bitmap: Uint32Array = new Uint32Array(imgData.data.buffer); + + let debugStats = { + transparent: 0, + painted: 0, + byValue: {} as Record, + sampleValues: [] as number[] + }; + + + for (let y: number = 0; y < height; y++) { + for (let x: number = 0; x < width; x++) { + let ncIndex: number = x + y * width; + let value: number = floatArray[ncIndex]; + let pxIndex: number = x + ((height - 1) - y) * width; + + if (!isNaN(value) && isFinite(value)) { + if (uncertaintyLayer) { + if (value > 0) { + // Hay incertidumbre, pintar gris (índice 0 de la paleta) + bitmap[pxIndex] = gradient[0]; + debugStats.painted++; + + if (debugStats.sampleValues.length < 20) { + debugStats.sampleValues.push(value); + } + } else { + // Sin incertidumbre, transparente + bitmap[pxIndex] = pxTransparent; + debugStats.transparent++; + } + + // Contar distribución de valores + const roundedVal = Math.round(value * 10) / 10; + debugStats.byValue[roundedVal] = (debugStats.byValue[roundedVal] || 0) + 1; + + } else { let index: number = this.getValIndex(value); if (index >= 0 && index < gradient.length) { bitmap[pxIndex] = gradient[index]; + debugStats.painted++; } else { bitmap[pxIndex] = pxTransparent; + debugStats.transparent++; } - } else { - bitmap[pxIndex] = pxTransparent; } + } else { + bitmap[pxIndex] = pxTransparent; + debugStats.transparent++; } } - - context.putImageData(imgData, 0, 0); - return canvas; } + + context.putImageData(imgData, 0, 0); + return canvas; +} - getValIndex(val: number): number { - // Buscar el rango apropiado - for (let i = 0; i < this.ranges.length; i++) { - let range = this.ranges[i]; - - // Rango con límite inferior indefinido: val < b - if (range.a === undefined && val < range.b) { - return i; - } - // Rango con límite superior indefinido: val >= a - else if (range.b === undefined && val >= range.a) { - return i; - } - // Rango normal: a <= val < b - else if (range.a !== undefined && range.b !== undefined) { - if (val >= range.a && val < range.b) { - return i; - } - } - } +getValIndex(val: number): number { + if (isNaN(val) || !isFinite(val)) { + return -1; + } + + for (let i = 0; i < this.ranges.length; i++) { + let range = this.ranges[i]; - return -1; // No encontrado + const a = (typeof range.a === 'number') ? range.a : -Infinity; + const b = (typeof range.b === 'number') ? range.b : Infinity; + + const isLastRange = (i === this.ranges.length - 1); + + if (val >= a && (val < b || (isLastRange && val <= b))) { + return i; + } } + + // Si llegamos aquí, el valor no cayó en ningún rango + return -1; +} getColorString(val: number, min: number, max: number): string { let mgr = PaletteManager.getInstance(); @@ -261,6 +286,8 @@ export class CategoryRangePainter implements Painter { return "#000000"; } + + } export class GradientPainter implements Painter{ @@ -339,6 +366,14 @@ export class CsDynamicPainter implements Painter{ this.precalculatedBreaks = niceSteps.getRegularSteps(dataForBreaks.filter(v => !isNaN(v)), maxPaletteSteps, maxPaletteValue); } + /** + * Método para establecer breaks directamente (sin usar getRegularSteps) + * Útil cuando los breaks ya están calculados (ej: getLegendValues) + */ + public setDirectBreaks(breaks: number[]): void { + this.precalculatedBreaks = [...breaks]; + } + /** * Método para limpiar los breaks precalculados */ diff --git a/anemui-core/src/data/ChunkDownloader.ts b/anemui-core/src/data/ChunkDownloader.ts index 7a08898..9ef42a0 100644 --- a/anemui-core/src/data/ChunkDownloader.ts +++ b/anemui-core/src/data/ChunkDownloader.ts @@ -10,7 +10,7 @@ import { fromLonLat } from "ol/proj"; import { CategoryRangePainter, PaletteManager } from "../PaletteManager"; import { BaseApp } from "../BaseApp"; import Static from "ol/source/ImageStatic"; -import { ncSignif, dataSource, computedDataTilesLayer, olProjection } from "../Env"; +import { ncSignif, dataSource, globalMap, olProjection } from "../Env"; import * as fs from 'fs'; import * as path from 'path'; import { NestedArray, openArray, TypedArray } from 'zarr'; @@ -275,12 +275,12 @@ export async function buildImages(promises: Promise[], dataTilesLayer: const validFloatArrays = floatArrays.filter(arr => arr !== undefined && arr !== null); if (validFloatArrays.length === 0) { + console.warn('No valid float arrays received'); return; } const actualTimeIndex = getActualTimeIndex(status.selectedTimeIndex, status.varId, timesJs); - if (!Array.isArray(timesJs.varMin[status.varId])) { timesJs.varMin[status.varId] = []; } @@ -291,7 +291,9 @@ export async function buildImages(promises: Promise[], dataTilesLayer: const filteredArrays: number[][] = []; for (let i = 0; i < validFloatArrays.length; i++) { - const filteredArray = await app.filterValues(validFloatArrays[i], actualTimeIndex, status.varId, timesJs.portions[status.varId][i]); + + const filteredArray = validFloatArrays[i]; + filteredArrays.push(filteredArray); } @@ -349,6 +351,7 @@ export async function buildImages(promises: Promise[], dataTilesLayer: maxArray = 100; } + try { (timesJs.varMin[status.varId] as number[])[actualTimeIndex] = minArray; (timesJs.varMax[status.varId] as number[])[actualTimeIndex] = maxArray; @@ -410,7 +413,6 @@ export async function buildImages(promises: Promise[], dataTilesLayer: } dataTilesLayer[i].setVisible(uncertaintyLayer ? false : true); dataTilesLayer[i].setOpacity(1.0); - dataTilesLayer[i].changed(); await new Promise(resolve => setTimeout(resolve, 50)); @@ -443,15 +445,12 @@ export async function buildImages(promises: Promise[], dataTilesLayer: if (window.CsViewerApp && (window.CsViewerApp as any).csMap) { const map = (window.CsViewerApp as any).csMap.map; if (map) { - map.render(); - await new Promise(resolve => setTimeout(resolve, 100)); - if (map.renderSync) { map.renderSync(); } - } else { + } else { console.warn('Map not available'); } } @@ -488,7 +487,6 @@ let xyCache: { } = undefined async function downloadXYChunkNC(t: number, varName: string, portion: string, timesJs: CsTimesJsData): Promise { - let app = window.CsViewerApp; const actualTimeIndex = getActualTimeIndex(t, varName, timesJs); @@ -525,19 +523,30 @@ async function downloadXYChunkNC(t: number, varName: string, portion: string, ti const chunk = await rangeRequest(ncUrl, BigInt(chunkOffset), BigInt(chunkOffset) + BigInt(chunkSize) - BigInt(1)); const uncompressedArray = inflate(chunk); - const floatArray = Array.from(chunkStruct.iter_unpack(uncompressedArray.buffer), x => x[0]); if (!Array.isArray(floatArray) || floatArray.length === 0) { throw new Error(`Invalid float array: length=${floatArray.length}, isArray=${Array.isArray(floatArray)}`); } + const validCount = floatArray.filter(v => !isNaN(v) && isFinite(v)).length; + console.log('🔍 downloadXYChunkNC OUTPUT:', { + varName, + portion, + actualTimeIndex, + total: floatArray.length, + valid: validCount, + validPercent: (validCount / floatArray.length * 100).toFixed(2) + '%', + samples: floatArray.slice(0, 20), + min: Math.min(...floatArray.filter(v => !isNaN(v) && isFinite(v))), + max: Math.max(...floatArray.filter(v => !isNaN(v) && isFinite(v))) + }); + xyCache = { t: actualTimeIndex, varName, portion, data: [...floatArray] }; let ret = [...floatArray]; app.transformDataXY(ret, actualTimeIndex, varName, portion); - return ret; } catch (error) { @@ -648,7 +657,7 @@ export function extractValueChunkedFromT(latlng: CsLatLong, functionValue: TileA export function extractValueChunkedFromXY(latlng: CsLatLong, functionValue: TileArrayCB, errorCb: DownloadErrorCB, status: CsViewerData, times: CsTimesJsData, int: boolean = false): void { let ncCoords: number[] = fromLonLat([latlng.lng, latlng.lat], times.projection); let portion: string = getPortionForPoint(ncCoords, times, status.varId); - if (portion != '') { + if (portion != '' || globalMap) { const chunkIndex: number = calcPixelIndex(ncCoords, portion); if (status.computedLayer) { @@ -771,6 +780,62 @@ export function downloadXYbyRegion(time: string, timeIndex: number, folder: stri }, undefined, 'text'); } +export function downloadXYbyRegionMultiPortion( + time: string, + timeIndex: number, + folder: string, + varName: string, + portions: string[], + doneCb: (mergedData: any, filename: string, type: string) => void +) { + + const promises = portions.map(portion => { + return new Promise((resolve, reject) => { + const csvPath = `./regData/${folder}/${varName}${portion}.csv`; + console.log(" 📥 Downloading:", csvPath); + + downloadUrl(csvPath, (status: number, response) => { + if (status == 200) { + try { + const records = parse(response as Buffer, { + columns: true, + skip_empty_lines: true + }); + resolve({ portion, data: records[timeIndex] || {} }); + } catch (e) { + console.error(`Error parsing CSV ${varName}${portion}:`, e); + reject(e); + } + } else { + console.error(`HTTP ${status} for ${csvPath}`); + reject(new Error(`HTTP ${status}`)); + } + }, undefined, 'text'); + }); + }); + + Promise.all(promises) + .then((results: any[]) => { + + const mergedData: any = {}; + results.forEach(({ portion, data }) => { + Object.keys(data).forEach(key => { + if (key !== 'times_ini' && key !== 'times_end' && key !== 'times_mean') { + if (!mergedData[key] || isNaN(mergedData[key])) { + mergedData[key] = data[key]; + } + } + }); + }); + + doneCb(mergedData, varName, 'text/plain'); + }) + .catch(error => { + console.error("Error loading region portions:", error); + doneCb({}, varName, 'text/plain'); + }); +} + export function downloadHistoricalDataForPercentile( latlng: CsLatLong, varId: string, diff --git a/anemui-core/src/data/CsDataTypes.ts b/anemui-core/src/data/CsDataTypes.ts index 6c36f0a..a22eb19 100644 --- a/anemui-core/src/data/CsDataTypes.ts +++ b/anemui-core/src/data/CsDataTypes.ts @@ -121,7 +121,9 @@ export type CsGeoJsonData={ export enum CsTimeSpan{ Date, + Day, // 365/366 días del año (climatología diaria) Month, Season, - Year + Year, + YearSeries } \ No newline at end of file diff --git a/anemui-core/src/index.ts b/anemui-core/src/index.ts index 514e692..ee9e279 100644 --- a/anemui-core/src/index.ts +++ b/anemui-core/src/index.ts @@ -16,6 +16,7 @@ export { enableRenderer } from './tiles/Support'; export { CsGraph, type GraphType, ColorLegendConfig } from './ui/Graph'; export { MenuBar, MenuBarListener, simpleDiv } from './ui/MenuBar'; export { DateSelectorFrame, DateFrameListener, DateFrameMode } from "./ui/DateFrame"; +export { default as PaletteFrame } from './ui/PaletteFrame'; export { fromLonLat } from 'ol/proj'; @@ -23,4 +24,4 @@ export { fromLonLat } from 'ol/proj'; export type { CsvDownloadDone } from './data/ChunkDownloader'; export { CsLatLong } from './CsMapTypes'; export { downloadUrl } from './data/UrlDownloader'; -export { downloadXYArrayChunked, downloadXYChunk, downloadTCSVChunked, getPortionForPoint, downloadXYbyRegion, downloadCSVbyRegion, downloadCSVbySt, downloadTimebyRegion, calcPixelIndex, downloadTArrayChunked } from './data/ChunkDownloader'; +export { downloadXYArrayChunked, downloadXYChunk, downloadTCSVChunked, getPortionForPoint, downloadXYbyRegion, downloadCSVbyRegion, downloadCSVbySt, downloadTimebyRegion, calcPixelIndex, downloadTArrayChunked, downloadXYbyRegionMultiPortion } from './data/ChunkDownloader'; diff --git a/anemui-core/src/ui/DateFrame.tsx b/anemui-core/src/ui/DateFrame.tsx index 154da90..fde9cd8 100644 --- a/anemui-core/src/ui/DateFrame.tsx +++ b/anemui-core/src/ui/DateFrame.tsx @@ -8,6 +8,7 @@ import { default as Slider } from 'bootstrap-slider'; import { BaseFrame, BaseUiElement, mouseOverFrame } from './BaseFrame'; import { BaseApp } from '../BaseApp'; import { CsDropdown, CsDropdownListener } from './CsDropdown'; +import { CsTimeSpan } from "../data/CsDataTypes"; import { locale } from '../Env'; @@ -30,9 +31,12 @@ type yearHashMap = { export enum DateFrameMode{ DateFrameDate, + DateFrameDay, DateFrameSeason, DateFrameMonth, DateFrameYear, + DateFrameYearSeries, + ClimFrameDay, ClimFrameSeason, ClimFrameMonth, ClimFrameYear, @@ -218,6 +222,7 @@ export class DateSelectorFrame extends BaseFrame { } break; case DateFrameMode.DateFrameYear: + case DateFrameMode.DateFrameYearSeries: if (_varChanged) { this.yearIndex = {}; this.years = []; @@ -229,7 +234,7 @@ export class DateSelectorFrame extends BaseFrame { } this.updateDatepicker(); break; - default: + default: break; } } @@ -289,6 +294,7 @@ export class DateSelectorFrame extends BaseFrame { } break; case DateFrameMode.DateFrameYear: + case DateFrameMode.DateFrameYearSeries: if (this.years && this.years.length > 0) { const currentYear = this.years[Math.min(safeIndex, this.years.length - 1)]; const startYear = this.years[0]; @@ -341,6 +347,7 @@ export class DateSelectorFrame extends BaseFrame { if (this.seasonIndex[year][season] == undefined) season = (lastDate.getMonth() + 1) + "" return this.seasonIndex[year][season]; case DateFrameMode.DateFrameYear: + case DateFrameMode.DateFrameYearSeries: if (this.yearIndex == undefined) return -1; if (this.yearIndex[year] == undefined) return -1; return this.yearIndex[year]; @@ -365,11 +372,11 @@ export class DateSelectorFrame extends BaseFrame { } private isYearValid(date:Date):boolean{ - if (this.mode === DateFrameMode.DateFrameYear) { + if (this.mode === DateFrameMode.DateFrameYear || this.mode === DateFrameMode.DateFrameYearSeries) { const year = date.getFullYear().toString(); return this.yearIndex && this.yearIndex[year] !== undefined; } - + if (this.dateIndex == undefined) return false; let year: string; year = date.getFullYear() + "" @@ -496,6 +503,7 @@ export class DateSelectorFrame extends BaseFrame { this.pickerNotClicked = true; break; case DateFrameMode.DateFrameYear: + case DateFrameMode.DateFrameYearSeries: options = { format: "yyyy", autoclose: true, @@ -717,68 +725,51 @@ export class DateSelectorFrame extends BaseFrame { this.sliderFrame.hidden = false; switch (timeSpan) { - case 3: - this.mode = DateFrameMode.DateFrameYear; - this.sliderFrame.hidden = true; - break; - case 2: - this.mode = DateFrameMode.DateFrameSeason; - break; - case 1: - this.mode = DateFrameMode.DateFrameMonth; + case CsTimeSpan.YearSeries: + this.mode = DateFrameMode.DateFrameYearSeries; break; - default: - this.mode = DateFrameMode.DateFrameDate; - break; - } - - /* switch (time) { - case 1: + case CsTimeSpan.Year: this.mode = DateFrameMode.DateFrameYear; this.sliderFrame.hidden = true; break; - case 4: + case CsTimeSpan.Season: this.mode = DateFrameMode.DateFrameSeason; break; - case 12: + case CsTimeSpan.Month: this.mode = DateFrameMode.DateFrameMonth; break; - default: + case CsTimeSpan.Day: + this.mode = DateFrameMode.DateFrameDay; + break; + default: // CsTimeSpan.Date (0) this.mode = DateFrameMode.DateFrameDate; break; - } */ + } } } else { this.timeSeriesFrame.hidden = true; switch (timeSpan) { - case 3: + case CsTimeSpan.Year: this.mode = DateFrameMode.ClimFrameYear; this.sliderFrame.hidden = true; this.climatologyFrame.hidden = true; break; - default: - this.mode = timeSpan == 2? DateFrameMode.ClimFrameSeason:DateFrameMode.ClimFrameMonth; - let period = this.getPeriods(); - this.climTitle.innerHTML = period[this.parent.getState().selectedTimeIndex]; + case CsTimeSpan.Day: + this.mode = DateFrameMode.ClimFrameDay; + let periodDay = this.getPeriods(); + this.climTitle.innerHTML = periodDay[this.parent.getState().selectedTimeIndex]; this.sliderFrame.hidden = false; this.climatologyFrame.hidden = false; break; - } - /* switch (time) { - case 1: - this.mode = DateFrameMode.ClimFrameYear; - this.sliderFrame.hidden = true; - this.climatologyFrame.hidden = true; - break; - default: - this.mode = time==4? DateFrameMode.ClimFrameSeason:DateFrameMode.ClimFrameMonth; - let period = this.getPeriods(time); + default: // CsTimeSpan.Season (3) or CsTimeSpan.Month (2) + this.mode = timeSpan == 3? DateFrameMode.ClimFrameSeason:DateFrameMode.ClimFrameMonth; + let period = this.getPeriods(); this.climTitle.innerHTML = period[this.parent.getState().selectedTimeIndex]; this.sliderFrame.hidden = false; this.climatologyFrame.hidden = false; break; - } */ + } } } @@ -797,7 +788,7 @@ export class DateSelectorFrame extends BaseFrame { public getPeriods(): string[] { const timeSpan = this.getTimeSpan() - if (timeSpan == 2) { + if (timeSpan == CsTimeSpan.Season) { // Para estaciones, devolver los valores del objeto season const season = this.parent.getTranslation('season'); return Object.values(season); diff --git a/anemui-core/src/ui/Graph.tsx b/anemui-core/src/ui/Graph.tsx index 777768d..4682821 100644 --- a/anemui-core/src/ui/Graph.tsx +++ b/anemui-core/src/ui/Graph.tsx @@ -110,8 +110,11 @@ export class CsGraph extends BaseFrame { public render(): JSX.Element { let self = this; - let graphWidth = screen.width > 1200 ? screen.width * 0.4 : screen.width * 0.55; - let graphHeight = screen.height > 1200 ? screen.height * 0.4 : screen.height * 0.50; + // Calcular tamaño responsivo del gráfico + const maxWidth = Math.min(screen.width * 0.85, 900); + const maxHeight = Math.min(screen.height * 0.65, 500); + let graphWidth = screen.width > 1200 ? Math.min(screen.width * 0.4, maxWidth) : Math.min(screen.width * 0.55, maxWidth); + let graphHeight = screen.height > 900 ? Math.min(screen.height * 0.4, maxHeight) : Math.min(screen.height * 0.50, maxHeight); let element = (
-
+