diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0a82caf78..7c544fbca 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -8,7 +8,6 @@ # * OSMOSE_DB_USER : DB user (ex: osmose) # * OSMOSE_DB_PWD : DB password # * OSMOSE_DB_BASE : DB Aplose dedicated database name (ex: osmose) -# * OSMOSE_PROXY_URL : Proxy host:port if needed (ex: proxy-wiz.osmose.fr:3128) # * Exposed port: 8000 # * Folder dedicated to "datawork" mount: /opt/datawork # * Folder dedicated to server static files mount: /opt/staticfiles diff --git a/backend/api/models/data/spectrogram.py b/backend/api/models/data/spectrogram.py index c88dca735..b85629503 100644 --- a/backend/api/models/data/spectrogram.py +++ b/backend/api/models/data/spectrogram.py @@ -1,18 +1,23 @@ """Spectrogram model""" import csv from datetime import datetime, timedelta +from os import path from os.path import join -from pathlib import Path +from pathlib import Path, PureWindowsPath from django.conf import settings from django.db import models from django.db.models import Q, F, QuerySet +from django.utils import timezone from metadatax.data.models import FileFormat from osekit.config import TIMESTAMP_FORMAT_EXPORTED_FILES_LOCALIZED - -# from osekit.core_api.spectro_data import SpectroData -# from osekit.core_api.spectro_dataset import SpectroDataset -from backend.utils.osekit_replace import SpectroDataset, SpectroData +from osekit.core_api.audio_data import AudioData +from osekit.core_api.audio_file import AudioFile +from osekit.core_api.spectro_data import SpectroData +from osekit.core_api.spectro_dataset import SpectroDataset +from pandas import Timestamp +from scipy.signal import ShortTimeFFT +from scipy.signal.windows import hamming from .__abstract_file import AbstractFile from .__abstract_time_segment import TimeSegment @@ -157,6 +162,110 @@ def __str__(self): analysis = models.ManyToManyField(SpectrogramAnalysis, related_name="spectrograms") + def get_audio_path(self, analysis: SpectrogramAnalysis) -> str: + if analysis.dataset.legacy: + folders = PureWindowsPath(analysis.path).as_posix().split("/") + folders.pop() + return path.join( + analysis.dataset.path.split( + settings.DATASET_EXPORT_PATH.stem + "/" + ).pop(), + PureWindowsPath(settings.DATASET_FILES_FOLDER), + PureWindowsPath(folders.pop()), + PureWindowsPath(f"{self.filename}.wav"), + ) + else: + spectro_data: SpectroData = self.get_spectro_data_for(analysis) + audio_files = list(spectro_data.audio_data.files) + if len(audio_files) != 1: + return None + + audio_file = audio_files[0] + if audio_file.begin != ( + self.start if audio_file.begin.tz else timezone.make_naive(self.start) + ): + return None + if audio_file.end < ( + self.end if audio_file.end.tz else timezone.make_naive(self.end) + ): + return None + + audio_path = str(audio_file.path) + return ( + audio_path.split(str(settings.DATASET_EXPORT_PATH)).pop().lstrip("\\") + ) + + def get_base_spectro_path(self, analysis: SpectrogramAnalysis) -> str: + if analysis.dataset.legacy: + return path.join( + PureWindowsPath( + analysis.dataset.path.split( + settings.DATASET_EXPORT_PATH.stem + "/" + ).pop() + ), + PureWindowsPath(analysis.path), + PureWindowsPath("image"), + PureWindowsPath(f"{self.filename}.{self.format.name}"), + ) + else: + spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() + spectro_dataset_path = str(spectro_dataset.folder).split( + str(settings.DATASET_EXPORT_PATH) + )[1] + return path.join( + PureWindowsPath(spectro_dataset_path), + PureWindowsPath("spectrogram"), # TODO: avoid static path parts!!! + PureWindowsPath(f"{self.filename}.{self.format.name}"), + ).lstrip("\\") + def get_spectro_data_for(self, analysis: SpectrogramAnalysis) -> SpectroData: - spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() - return [d for d in spectro_dataset.data if d.name == self.filename].pop() + if analysis.legacy: + audio_path = Path( + join( + settings.VOLUMES_ROOT, + settings.DATASET_EXPORT_PATH, + self.get_audio_path(analysis), + ) + ) + audio_file = AudioFile( + path=audio_path, + begin=Timestamp.fromisoformat(self.start.isoformat()), + ) + audio_data = AudioData.from_files([audio_file]) + overlap = analysis.fft.overlap or 0.95 + hop = round(analysis.fft.window_size * (1 - overlap)) + return SpectroData.from_audio_data( + data=audio_data, + fft=ShortTimeFFT( + win=hamming(analysis.fft.window_size), + hop=hop, + fs=analysis.fft.sampling_frequency, + scale_to="magnitude", + ), + colormap="viridis", # This is the default value + ) + else: + spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() + return [d for d in spectro_dataset.data if d.name == self.filename].pop() + + def get_audio_data_for(self, analysis: SpectrogramAnalysis) -> AudioData: + if analysis.legacy: + audio_path = Path( + join( + settings.VOLUMES_ROOT, + settings.DATASET_EXPORT_PATH, + self.get_audio_path(analysis), + ) + ) + audio_file = AudioFile( + path=audio_path, + begin=Timestamp.fromisoformat(self.start.isoformat()), + ) + return AudioData.from_files([audio_file]) + else: + spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() + return ( + [d for d in spectro_dataset.data if d.name == self.filename] + .pop() + .audio_data + ) diff --git a/backend/api/models/data/spectrogram_analysis.py b/backend/api/models/data/spectrogram_analysis.py index bc12f9f0f..ae5b61a9c 100644 --- a/backend/api/models/data/spectrogram_analysis.py +++ b/backend/api/models/data/spectrogram_analysis.py @@ -14,8 +14,8 @@ from backend.aplose.models import User -# from osekit.core_api.spectro_dataset import SpectroDataset -from backend.utils.osekit_replace import SpectroDataset +from osekit.core_api.spectro_dataset import SpectroDataset + from .__abstract_analysis import AbstractAnalysis from .colormap import Colormap from .dataset import Dataset diff --git a/backend/api/schema/nodes/annotation_spectrogram.py b/backend/api/schema/nodes/annotation_spectrogram.py index 0a5cd47c6..d4ffb7a02 100644 --- a/backend/api/schema/nodes/annotation_spectrogram.py +++ b/backend/api/schema/nodes/annotation_spectrogram.py @@ -5,16 +5,11 @@ import graphene import graphene_django_optimizer from django.conf import settings -from django.utils import timezone from django_extension.schema.errors import NotFoundError from django_extension.schema.fields import AuthenticatedPaginationConnectionField from django_extension.schema.types import ExtendedNode from graphql import GraphQLResolveInfo -# from osekit.core_api.spectro_data import SpectroData -# from osekit.core_api.spectro_dataset import SpectroDataset -from backend.utils.osekit_replace import SpectroDataset, SpectroData - from backend.api.models import ( Spectrogram, AnnotationCampaign, @@ -102,43 +97,10 @@ def resolve_is_assigned( @graphene_django_optimizer.resolver_hints() def resolve_audio_path(self: Spectrogram, info, analysis_id: int): analysis: SpectrogramAnalysis = self.analysis.get(id=analysis_id) - - audio_path: str - if analysis.dataset.legacy: - folders = PureWindowsPath(analysis.path).as_posix().split("/") - folders.pop() - audio_path = path.join( - analysis.dataset.path.split( - settings.DATASET_EXPORT_PATH.stem + "/" - ).pop(), - PureWindowsPath(settings.DATASET_FILES_FOLDER), - PureWindowsPath(folders.pop()), - PureWindowsPath(f"{self.filename}.wav"), - ) - else: - spectro_data: SpectroData = self.get_spectro_data_for(analysis) - audio_files = list(spectro_data.audio_data.files) - if len(audio_files) != 1: - return None - - audio_file = audio_files[0] - if audio_file.begin != ( - self.start if audio_file.begin.tz else timezone.make_naive(self.start) - ): - return None - if audio_file.end < ( - self.end if audio_file.end.tz else timezone.make_naive(self.end) - ): - return None - - audio_path = str(audio_file.path) - audio_path = ( - audio_path.split(str(settings.DATASET_EXPORT_PATH)).pop().lstrip("\\") - ) return path.join( PureWindowsPath(settings.STATIC_URL), PureWindowsPath(settings.DATASET_EXPORT_PATH), - PureWindowsPath(audio_path), + PureWindowsPath(self.get_audio_path(analysis)), ) path = graphene.String(analysis_id=graphene.ID(required=True), required=True) @@ -146,33 +108,10 @@ def resolve_audio_path(self: Spectrogram, info, analysis_id: int): @graphene_django_optimizer.resolver_hints() def resolve_path(self: Spectrogram, info, analysis_id: int): analysis: SpectrogramAnalysis = self.analysis.get(id=analysis_id) - - spectrogram_path: str - if analysis.dataset.legacy: - spectrogram_path = path.join( - PureWindowsPath( - analysis.dataset.path.split( - settings.DATASET_EXPORT_PATH.stem + "/" - ).pop() - ), - PureWindowsPath(analysis.path), - PureWindowsPath("image"), - PureWindowsPath(f"{self.filename}.{self.format.name}"), - ) - else: - spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() - spectro_dataset_path = str(spectro_dataset.folder).split( - str(settings.DATASET_EXPORT_PATH) - )[1] - spectrogram_path = path.join( - PureWindowsPath(spectro_dataset_path), - PureWindowsPath("spectrogram"), # TODO: avoid static path parts!!! - PureWindowsPath(f"{self.filename}.{self.format.name}"), - ).lstrip("\\") return path.join( PureWindowsPath(settings.STATIC_URL), PureWindowsPath(settings.DATASET_EXPORT_PATH), - PureWindowsPath(spectrogram_path), + PureWindowsPath(self.get_base_spectro_path(analysis)), ) task = graphene.Field( diff --git a/backend/api/urls.py b/backend/api/urls.py index 0eaf15a07..8058b1591 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -5,8 +5,12 @@ AnnotationViewSet, DownloadViewSet, ) +from backend.api.views.zoom import ZoomViewSet # API urls are meant to be used by our React frontend api_router = routers.DefaultRouter() api_router.register(r"annotation", AnnotationViewSet, basename="annotation") api_router.register("download", DownloadViewSet, basename="download") + + +api_router.register("data", ZoomViewSet, basename="data-zoom") diff --git a/backend/api/views/zoom.py b/backend/api/views/zoom.py new file mode 100644 index 000000000..9a3b6dedb --- /dev/null +++ b/backend/api/views/zoom.py @@ -0,0 +1,154 @@ +from http import HTTPStatus +from io import BytesIO +from os import path +from pathlib import PureWindowsPath + +import matplotlib.pyplot as plt +from django.conf import settings +from django.http import HttpResponse, HttpResponseRedirect, QueryDict +from django.shortcuts import get_object_or_404 +from django.templatetags.static import static +from osekit.core_api.audio_file import AudioFile +from osekit.core_api.audio_data import AudioData +from osekit.core_api.spectro_data import SpectroData +from pandas import Timestamp +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from scipy.signal import ShortTimeFFT +from scipy.signal.windows import hamming + +from backend.api.models import Spectrogram, SpectrogramAnalysis + + +class ZoomViewSet(ViewSet): + """Zoom view set""" + + @action( + detail=False, + url_path="analysis/(?P[^/.]+)/spectrogram/(?P[^/.]+)", + url_name="zoom", + ) + def zoom( + self, + request: Request, + analysis_id=None, + spectrogram_id=None, + ): + mode = request.query_params.get("mode", "png") + zoom = int(request.query_params.get("zoom", 0)) + tile = int(request.query_params.get("tile", 0)) + + spectrogram: Spectrogram = get_object_or_404(Spectrogram, pk=spectrogram_id) + analysis: SpectrogramAnalysis = get_object_or_404( + SpectrogramAnalysis, pk=analysis_id + ) + + if mode == "png": + return self.get_from_png(analysis, spectrogram, zoom, tile) + elif mode == "wav": + return self.get_from_wav( + request.query_params, analysis, spectrogram, zoom, tile + ) + return HttpResponse( + f"Mode not implemented ({mode})", status=HTTPStatus.NOT_IMPLEMENTED + ) + + def get_from_png( + self, + analysis: SpectrogramAnalysis, + spectrogram: Spectrogram, + zoom=0, + tile=0, + ): + base_path = spectrogram.get_base_spectro_path(analysis) + if analysis.legacy: + f = spectrogram.filename + image_path = f"{base_path.split(f)[0]}{ f }_{ zoom + 1 }_{ tile }{ base_path.split(f)[1] }" + else: + if zoom != 0 or tile != 0: + return HttpResponse( + f"Cannot query other than 0 level for new OSEkit format ({zoom}-{tile} requested).", + status=HTTPStatus.BAD_REQUEST, + ) + else: + image_path = base_path + + local_path = path.join( + PureWindowsPath(settings.VOLUMES_ROOT), + PureWindowsPath(settings.DATASET_EXPORT_PATH), + PureWindowsPath(image_path), + ) + if not path.exists(local_path): + return HttpResponse( + f"Image {local_path} not found.", status=HTTPStatus.NOT_FOUND + ) + + static_path = static( + path.join( + PureWindowsPath(settings.DATASET_EXPORT_PATH), + PureWindowsPath(image_path), + ) + ) + return HttpResponseRedirect(static_path) + + def get_from_wav( + self, + query_params: QueryDict, + analysis: SpectrogramAnalysis, + spectrogram: Spectrogram, + zoom=0, + tile=0, + ): + zoom_level = pow(2, zoom) + win_size = int(query_params.get("windowSize", analysis.fft.window_size)) + overlap = query_params.get("overlap", analysis.fft.overlap) + mfft = int(query_params.get("nfft", analysis.fft.nfft)) + fft = ShortTimeFFT( + mfft=mfft, + win=hamming(win_size), + hop=round(win_size * (1 - float(overlap))), + fs=analysis.fft.sampling_frequency, + scale_to="magnitude", + ) + + if analysis.legacy: + audio_file = AudioFile( + path=spectrogram.get_audio_path(analysis), + begin=Timestamp(str(spectrogram.start)), + ) + audio_data = AudioData.from_files([audio_file]) + spectro_data = SpectroData.from_audio_data( + data=audio_data, + fft=fft, + colormap="viridis", # This is the default value + ) + else: + # Check matrix exists + spectro_data: SpectroData = spectrogram.get_spectro_data_for(analysis) + spectro_data.fft = fft + + spectro_data = spectro_data.split(zoom_level)[tile] + + colormap = query_params.get("colormap", None) + if colormap is not None: + spectro_data.colormap = colormap + + spectro_data.plot() + + # Get the (plotted) image into memory file + imgdata = BytesIO() + plt.savefig( + imgdata, + transparent=False, + format="png", + bbox_inches="tight", + pad_inches=0, + dpi=72, + ) + imgdata.seek(0) # rewind the data + + response = HttpResponse(content_type="image/png") + # Write the value of our buffer to the response + response.write(imgdata.getvalue()) + return response diff --git a/backend/storage/schema/resolver/analysis.py b/backend/storage/schema/resolver/analysis.py index b1cfa1e27..7ec71917c 100644 --- a/backend/storage/schema/resolver/analysis.py +++ b/backend/storage/schema/resolver/analysis.py @@ -1,11 +1,9 @@ from typing import Optional from django.conf import settings +from osekit.core_api.spectro_dataset import SpectroDataset from backend.api.models import SpectrogramAnalysis as AnalysisModel - -# from osekit.core_api.spectro_dataset import SpectroDataset -from backend.utils.osekit_replace import SpectroDataset from .base_resolver import BaseResolver from .exceptions import AnalysisBrowseException from .types import ImportStatus diff --git a/backend/storage/schema/resolver/dataset.py b/backend/storage/schema/resolver/dataset.py index c73815d85..aadf846d5 100644 --- a/backend/storage/schema/resolver/dataset.py +++ b/backend/storage/schema/resolver/dataset.py @@ -5,10 +5,7 @@ from backend.api.models.data.dataset import Dataset as DatasetModel -# from osekit.public_api.dataset import ( -# SpectroDataset, -# ) -from backend.utils.osekit_replace import SpectroDataset, OSEkitDataset +from osekit.public_api.dataset import SpectroDataset, Dataset as OSEkitDataset from .analysis import Analysis from .base_resolver import BaseResolver from .types import LegacyCSVDataset, ImportStatus diff --git a/backend/utils/osekit_replace.py b/backend/utils/osekit_replace.py deleted file mode 100644 index d1dbd26b7..000000000 --- a/backend/utils/osekit_replace.py +++ /dev/null @@ -1,142 +0,0 @@ -import json -from os.path import join -from pathlib import PureWindowsPath, Path - -import numpy as np -from pandas import Timestamp, Timedelta -from scipy.signal import ShortTimeFFT - - -class TFile: - begin: Timestamp - end: Timestamp - path: str - - def __init__(self, d: dict, dataset_path): - self.begin = d["begin"] - self.end = d["end"] - self.path = join( - dataset_path, - PureWindowsPath(d["path"]) - .as_posix() - .split(PureWindowsPath(dataset_path).stem) - .pop() - .strip("/"), - ) - - -class AudioData: - dataset_path: Path - - files: list[TFile] - - def __init__(self, d: dict, dataset_path): - self.dataset_path = dataset_path - self.audio_data = [TFile(d, dataset_path) for name, d in d["files"].items()] - - -class SpectroData: - dataset_path: Path - name: str - v_lim: list[int] # TODO - begin: Timestamp - end: Timestamp - duration: Timedelta - audio_data: AudioData - - def __init__(self, d: dict, name, dataset_path): - self.dataset_path = dataset_path - self.name = name - self.begin = Timestamp(d["begin"]) - self.end = Timestamp(d["end"]) - self.v_lim = d["v_lim"] - self.duration = self.end - self.begin - self.audio_data = AudioData(d["audio_data"], dataset_path) - - -class SpectroDataset: - folder: Path - name: str - data: list[SpectroData] = [] - - def __init__(self, d: dict, path): - self.d = d - self.folder = path - self.name = d["name"] - - @property - def fft(self) -> ShortTimeFFT: - sft = list(self.d["sft"].values())[0] - return ShortTimeFFT( - win=np.array(sft["win"]), - hop=sft["hop"], - fs=sft["fs"], - mfft=sft["mfft"], - ) - - @property - def colormap(self) -> str | None: - return list(self.d["data"].values())[0]["colormap"] - - @property - def begin(self) -> Timestamp: - """Begin of the first data object.""" - return min(data.begin for data in self.data) - - @property - def end(self) -> Timestamp: - """End of the last data object.""" - return max(data.end for data in self.data) - - @property - def data_duration(self) -> Timedelta: - data_durations = [ - Timedelta(data.duration).round(freq="1s") for data in self.data - ] - return max(set(data_durations), key=data_durations.count) - - def load_data(self): - self.data = [ - SpectroData(d, name, self.folder) for name, d in self.d["data"].items() - ] - - @staticmethod - def from_json(json_path: Path) -> "SpectroDataset": - with json_path.open("r") as f: - return SpectroDataset( - json.loads(f.read()), - PureWindowsPath(json_path).as_posix()[ - : -len(f"/{json_path.stem}{json_path.suffix}") - ], - ) - - -class OSEkitDataset: - datasets: dict - - def __init__(self, d: dict, path): - self.datasets = {} - for name, dataset in d["datasets"].items(): - analysis_json_path = join( - path, - PureWindowsPath(dataset["json"]) - .as_posix() - .split(PureWindowsPath(path).stem) - .pop() - .strip("/"), - ) - self.datasets[name] = { - "class": dataset["class"], - "analysis": dataset["analysis"], - "dataset": SpectroDataset.from_json( - Path(PureWindowsPath(analysis_json_path).as_posix()) - ), - } - - @staticmethod - def from_json(json_path: Path) -> "OSEkitDataset": - with json_path.open("r") as f: - return OSEkitDataset( - json.loads(f.read()), - PureWindowsPath(json_path).as_posix()[: -len("/dataset.json")], - ) diff --git a/frontend/src/api/annotation/api.ts b/frontend/src/api/annotation/api.ts index 3639034b9..5ffc8f0f8 100644 --- a/frontend/src/api/annotation/api.ts +++ b/frontend/src/api/annotation/api.ts @@ -25,7 +25,7 @@ export const AnnotationRestAPI = restAPI.injectEndpoints({ }>({ query: ({ campaignID, annotations, ...params }) => { return { - url: `annotation/campaign/${ campaignID }/phase/${ AnnotationPhaseType.Annotation }/`, + url: `/api/annotation/campaign/${ campaignID }/phase/${ AnnotationPhaseType.Annotation }/`, method: 'POST', params, body: { diff --git a/frontend/src/api/auth/api.ts b/frontend/src/api/auth/api.ts index 68a375747..81541b00e 100644 --- a/frontend/src/api/auth/api.ts +++ b/frontend/src/api/auth/api.ts @@ -8,7 +8,7 @@ export const AuthRestAPI = restAPI.injectEndpoints({ endpoints: (builder) => ({ login: builder.mutation({ query: (credentials) => ({ - url: 'token/', + url: '/api/token/', method: 'POST', body: credentials, }), diff --git a/frontend/src/api/baseRestApi.ts b/frontend/src/api/baseRestApi.ts index c93d66504..98ca38afa 100644 --- a/frontend/src/api/baseRestApi.ts +++ b/frontend/src/api/baseRestApi.ts @@ -3,7 +3,6 @@ import { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { prepareHeaders } from './utils'; const baseQueryWithHeaders = fetchBaseQuery({ - baseUrl: '/api/', prepareHeaders: prepareHeaders, }) @@ -24,7 +23,8 @@ export const restAPI = createApi({ tagTypes: [ 'SQL', 'Collaborator', + 'Tile', ], baseQuery: baseQueryWithReAuth, endpoints: () => ({}), -}) \ No newline at end of file +}) diff --git a/frontend/src/api/download/api.ts b/frontend/src/api/download/api.ts index b9931dbb5..2352578d6 100644 --- a/frontend/src/api/download/api.ts +++ b/frontend/src/api/download/api.ts @@ -9,7 +9,7 @@ export const DownloadRestAPI = restAPI.injectEndpoints({ downloadAnalysis: builder.mutation>({ query: ({ id, name }) => { return { - url: `/download/analysis-export/${ id }/`, + url: `/api/download/analysis-export/${ id }/`, responseHandler: getDownloadResponseHandler(`${ name }.zip`), } }, @@ -18,7 +18,7 @@ export const DownloadRestAPI = restAPI.injectEndpoints({ downloadAnnotations: builder.mutation({ query: ({ phaseID, campaignName }) => { return { - url: `/download/phase-annotations/${ phaseID }/`, + url: `/api/download/phase-annotations/${ phaseID }/`, responseHandler: getDownloadResponseHandler(`${ campaignName.replaceAll(' ', '_') }_results.csv`), } }, @@ -27,7 +27,7 @@ export const DownloadRestAPI = restAPI.injectEndpoints({ downloadProgress: builder.mutation({ query: ({ phaseID, campaignName }) => { return { - url: `/download/phase-progression/${ phaseID }/`, + url: `/api/download/phase-progression/${ phaseID }/`, responseHandler: getDownloadResponseHandler(`${ campaignName.replaceAll(' ', '_') }_status.csv`), } }, diff --git a/frontend/src/api/spectrogram/api.ts b/frontend/src/api/spectrogram/api.ts new file mode 100644 index 000000000..1f9463953 --- /dev/null +++ b/frontend/src/api/spectrogram/api.ts @@ -0,0 +1,20 @@ +import { restAPI } from '../baseRestApi'; +import type { FetchArgs } from '@reduxjs/toolkit/query'; + + +export const SpectrogramRestAPI = restAPI.injectEndpoints({ + endpoints: (builder) => ({ + getTile: builder.query({ + query: (args) => ({ + ...args, + responseHandler: async (response: Response) => { + console.debug('responseHandler', response); + if (response.status !== 200) throw new Error(response.statusText) + const blob = await response.blob() + return URL.createObjectURL(blob) + } + }), + providesTags: (_result, _error, arg) => [{type: 'Tile', id: JSON.stringify(arg)}] + }), + }), +}) diff --git a/frontend/src/api/spectrogram/index.ts b/frontend/src/api/spectrogram/index.ts new file mode 100644 index 000000000..201fc0fba --- /dev/null +++ b/frontend/src/api/spectrogram/index.ts @@ -0,0 +1 @@ +export * from './api' \ No newline at end of file diff --git a/frontend/src/api/sql/api.ts b/frontend/src/api/sql/api.ts index 7edd64c62..372e66914 100644 --- a/frontend/src/api/sql/api.ts +++ b/frontend/src/api/sql/api.ts @@ -7,7 +7,7 @@ const DEFAULT_PAGE_SIZE = 20; export const SQLRestAPI = restAPI.injectEndpoints({ endpoints: (builder) => ({ sqlSchema: builder.query<{ [key in string]: string[] }, void>({ - query: () => 'sql/schema/', + query: () => '/api/sql/schema/', providesTags: [ { type: 'SQL', id: 'schema' } ], }), postSQL: builder.mutation & { columns: string[] }, { @@ -16,7 +16,7 @@ export const SQLRestAPI = restAPI.injectEndpoints({ page_size?: number }>({ query: ({ query, page, page_size = DEFAULT_PAGE_SIZE }) => ({ - url: `sql/post/`, + url: `/api/sql/post/`, params: { page, page_size }, method: 'POST', body: { query }, diff --git a/frontend/src/components/ui/Modal/modal.hook.tsx b/frontend/src/components/ui/Modal/modal.hook.tsx index 59bf5a829..f1b0dd600 100644 --- a/frontend/src/components/ui/Modal/modal.hook.tsx +++ b/frontend/src/components/ui/Modal/modal.hook.tsx @@ -6,15 +6,12 @@ export type ModalProps = { onClose: () => void }; export const useModal = (component: React.FC, extraArgs?: object) => { const [ isOpen, setIsOpen ] = useState(false); const toggle = useCallback(() => { - console.log('toggle', component.name) setIsOpen(prev => !prev) }, [ setIsOpen, component ]) const open = useCallback(() => { - console.log('open', component.name) setIsOpen(true) }, [ setIsOpen , component]) const close = useCallback(() => { - console.log('close', component.name) setIsOpen(false) }, [ setIsOpen, component ]) diff --git a/frontend/src/components/ui/Scale/hooks.ts b/frontend/src/components/ui/Scale/hooks.ts index e16fff8bb..505ebb0e2 100644 --- a/frontend/src/components/ui/Scale/hooks.ts +++ b/frontend/src/components/ui/Scale/hooks.ts @@ -1,8 +1,8 @@ import { Step } from './types'; -import { useCallback, useEffect } from 'react'; +import { type MutableRefObject, useCallback, useEffect } from 'react'; -export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, displaySmallStepValue }: { - canvas?: HTMLCanvasElement | null, +export const useAxis = ({ canvasRef, steps, orientation, pixelSize, valueToString, displaySmallStepValue }: { + canvasRef?: MutableRefObject, steps: Step[], orientation: 'horizontal' | 'vertical', pixelSize: number, @@ -10,15 +10,11 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, displaySmallStepValue: boolean; }) => { - useEffect(() => { - draw() - }, [ canvas, pixelSize, valueToString, orientation, steps ]); - const draw = useCallback(() => { - const context = canvas?.getContext('2d'); - if (!canvas || !context || !pixelSize) return; + const context = canvasRef?.current?.getContext('2d'); + if (!canvasRef?.current || !context || !pixelSize) return; - context.clearRect(0, 0, canvas.width, canvas.height); + context.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); context.fillStyle = 'rgba(0, 0, 0)'; context.font = '500 10px \'Exo 2\''; @@ -46,10 +42,10 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, let position = step.position; switch (orientation) { case 'horizontal': - position = canvas.width - position; + position = canvasRef.current.width - position; break; case 'vertical': - position = canvas.height - position + position = canvasRef.current.height - position break; } @@ -72,10 +68,10 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, switch (orientation) { case 'vertical': - context.fillRect(canvas.width - tickLength, tickPosition, tickLength, tickWidth); + context.fillRect(canvasRef.current.width - tickLength, tickPosition, tickLength, tickWidth); break; case 'horizontal': - context.fillRect(tickPosition <= canvas.width - 2 ? tickPosition : canvas.width - 2, 0, tickWidth, tickLength); + context.fillRect(tickPosition <= canvasRef.current.width - 2 ? tickPosition : canvasRef.current.width - 2, 0, tickWidth, tickLength); break; } @@ -129,6 +125,10 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, } } } - }, [ canvas, steps, orientation, pixelSize, valueToString, displaySmallStepValue ]) + }, [ canvasRef, steps, orientation, pixelSize, valueToString, displaySmallStepValue ]) + + useEffect(() => { + draw() + }, [ canvasRef, pixelSize, valueToString, orientation, steps ]); } \ No newline at end of file diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 838705e28..774c84b63 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -19,3 +19,4 @@ export * from './Tooltip' export * from './popover.hook' export * from './toast.hook' +export * from './window.hook' diff --git a/frontend/src/components/ui/window.hook.ts b/frontend/src/components/ui/window.hook.ts new file mode 100644 index 000000000..38eb71935 --- /dev/null +++ b/frontend/src/components/ui/window.hook.ts @@ -0,0 +1,5 @@ +import { useMemo } from 'react'; + +export const useWindowRatio = () => { + return useMemo(() => window.devicePixelRatio * (1920 / (window.screen.width * window.devicePixelRatio)), []) +} diff --git a/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx b/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx index 151351dfa..f7816f2d3 100644 --- a/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx +++ b/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx @@ -14,7 +14,7 @@ import { Trend } from './Trend'; import { Duration } from '@/features/Annotator/AcousticFeatures/Duration'; import { NonLinearPhenomena } from '@/features/Annotator/AcousticFeatures/NonLinearPhenomena'; import { Checks } from '@/features/Annotator/AcousticFeatures/Checks'; -import { useWindowWidth } from '@/features/Annotator/Canvas'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const AcousticFeatures: React.FC = () => { const focusedAnnotation = useAppSelector(selectAnnotation) @@ -33,7 +33,7 @@ export const AcousticFeatures: React.FC = () => { }, [ getAnnotation, focusedAnnotation, dispatch ]) const divRef = useRef(null) - const windowWidth = useWindowWidth() + const { width: windowWidth } = useSpectrogramDimensions(0) const initialPosition = useMemo(() => { const initialLeft = window.innerWidth - 500 const position: ExtendedDivPosition ={ diff --git a/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx b/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx index 63c26c407..042a729ac 100644 --- a/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx +++ b/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx @@ -1,39 +1,74 @@ -import React, { useCallback, useMemo } from 'react'; -import { Select } from '@/components/form'; +import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { Input, Select } from '@/components/form'; import { useAppDispatch, useAppSelector } from '@/features/App'; -import { selectAllAnalysis, selectAnalysis } from './selectors'; -import { Analysis, setAnalysis } from './slice'; +import { selectAllAnalysis, selectAnalysis, selectFFT } from './selectors'; +import { setAnalysis, setFFT } from './slice'; +import { selectSpectrogramMode } from '@/features/Annotator/VisualConfiguration'; +import { Button } from '@/components/ui'; export const AnalysisSelect: React.FC = () => { - const allAnalysis = useAppSelector(selectAllAnalysis) - const analysis = useAppSelector(selectAnalysis) - const dispatch = useAppDispatch() - - const set = useCallback((value?: Analysis) => { - dispatch(setAnalysis(value)) - }, [ dispatch ]) - - const options = useMemo(() => { - return allAnalysis?.map(a => { - let label = `nfft: ${ a!.fft.nfft }`; - label += ` | winsize: ${ a!.fft.windowSize }` - label += ` | overlap: ${ a!.fft.overlap }` - label += ` | scale: ${ a!.legacyConfiguration?.scaleName ?? 'Default' }` - return { value: a!.id, label } - }) ?? [] - }, [ allAnalysis ]); - - const select = useCallback((value: string | number | undefined) => { - if (value === undefined) return; - const analysis = allAnalysis?.find(a => a?.id === (typeof value === 'number' ? value.toString() : value)) - if (analysis) set(analysis) - }, [ allAnalysis, set ]) - - return + default: + return + setNfft(e.currentTarget.valueAsNumber) }/> + setWindowSize(e.currentTarget.valueAsNumber) }/> + setOverlap(e.currentTarget.valueAsNumber) }/> + + + } } \ No newline at end of file diff --git a/frontend/src/features/Annotator/Analysis/selectors.ts b/frontend/src/features/Annotator/Analysis/selectors.ts index 24025a951..89a5c741b 100644 --- a/frontend/src/features/Annotator/Analysis/selectors.ts +++ b/frontend/src/features/Annotator/Analysis/selectors.ts @@ -7,6 +7,10 @@ export const selectAnalysisID = createSelector( selectAnnotator, AnnotatorAnalysisSlice.selectors.selectID, ) +export const selectFFT = createSelector( + selectAnnotator, AnnotatorAnalysisSlice.selectors.selectFFT, +) + export const selectAllAnalysis = createSelector( selectAnnotatorCampaign, diff --git a/frontend/src/features/Annotator/Analysis/slice.ts b/frontend/src/features/Annotator/Analysis/slice.ts index e2e329619..d86b77fe9 100644 --- a/frontend/src/features/Annotator/Analysis/slice.ts +++ b/frontend/src/features/Annotator/Analysis/slice.ts @@ -1,55 +1,62 @@ import { createSlice } from '@reduxjs/toolkit'; import type { GetCampaignQueryVariables } from '@/api'; import { ColormapNode, getCampaignFulfilled, type GetCampaignQuery, SpectrogramAnalysisNode } from '@/api'; +import type { FFT } from '@/features/Spectrogram/Display'; export type Analysis = Pick & { - colormap: Pick; + colormap: Pick; } | undefined type AnalysisState = { - id?: string; + id?: string; + fft?: Partial - _campaignID?: string; + _campaignID?: string; } export function getDefaultAnalysisID({ data, id }: { data: GetCampaignQuery, id?: string }) { - const allAnalysis = data?.annotationCampaignById?.analysis.edges.filter(e => !!e?.node).map(e => e!.node!) - // Select default analysis when none existing is selected - if (!allAnalysis || allAnalysis.length === 0 || allAnalysis.find(a => a.id === id)) return id; - const baseScaleAnalysis = allAnalysis.find(a => !a!.legacyConfiguration?.scaleName); - const minID = Math.min(...allAnalysis.map(a => +a!.id))?.toString(); - if (minID) return baseScaleAnalysis?.id ?? minID - return id + const allAnalysis = data?.annotationCampaignById?.analysis.edges.filter(e => !!e?.node).map(e => e!.node!) + // Select default analysis when none existing is selected + if (!allAnalysis || allAnalysis.length === 0 || allAnalysis.find(a => a.id === id)) return id; + const baseScaleAnalysis = allAnalysis.find(a => !a!.legacyConfiguration?.scaleName); + const minID = Math.min(...allAnalysis.map(a => +a!.id))?.toString(); + if (minID) return baseScaleAnalysis?.id ?? minID + return id } export const AnnotatorAnalysisSlice = createSlice({ - name: 'AnnotatorAnalysis', - initialState: { - _campaignID: undefined, - } as AnalysisState, - reducers: { - setAnalysis: (state, action: { payload: Analysis }) => { - state.id = action.payload?.id + name: 'AnnotatorAnalysis', + initialState: { + _campaignID: undefined, + } as AnalysisState, + reducers: { + setAnalysis: (state, action: { payload: Analysis }) => { + state.id = action.payload?.id + }, + setFFT: (state, action: { payload: Partial }) => { + state.fft = action.payload + }, + }, + extraReducers: builder => { + builder.addMatcher(getCampaignFulfilled, (state: AnalysisState, action: { + payload: GetCampaignQuery + meta: { arg: { originalArgs: GetCampaignQueryVariables } } + }) => { + if (state._campaignID !== action.payload.annotationCampaignById?.id) { + state._campaignID = action.payload.annotationCampaignById?.id + state.id = getDefaultAnalysisID({ data: action.payload }) + } else { + state.id = getDefaultAnalysisID({ data: action.payload, id: state.id }) + } + }) + }, + selectors: { + selectID: state => state.id, + selectFFT: state => state.fft, }, - }, - extraReducers: builder => { - builder.addMatcher(getCampaignFulfilled, (state: AnalysisState, action: { - payload: GetCampaignQuery - meta: { arg: { originalArgs: GetCampaignQueryVariables } } - }) => { - if (state._campaignID !== action.payload.annotationCampaignById?.id) { - state._campaignID = action.payload.annotationCampaignById?.id - state.id = getDefaultAnalysisID({ data: action.payload }) - } else { - state.id = getDefaultAnalysisID({ data: action.payload, id: state.id }) - } - }) - }, - selectors: { - selectID: state => state.id, - }, }) export const { - setAnalysis, + setAnalysis, + setFFT, } = AnnotatorAnalysisSlice.actions diff --git a/frontend/src/features/Annotator/Annotation/StrongAnnotation.tsx b/frontend/src/features/Annotator/Annotation/StrongAnnotation.tsx index d0772ebc8..82a1a4da6 100644 --- a/frontend/src/features/Annotator/Annotation/StrongAnnotation.tsx +++ b/frontend/src/features/Annotator/Annotation/StrongAnnotation.tsx @@ -16,7 +16,8 @@ import { formatTime } from '@/service/function'; import { useUpdateAnnotation } from '@/features/Annotator/Annotation/hooks'; import { AnnotationHeadContent } from '@/features/Annotator/Annotation/Head'; import { MOUSE_DOWN_EVENT } from '@/features/UX'; -import { useWindowHeight, useWindowWidth } from '@/features/Annotator/Canvas'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; +import { selectZoom } from '@/features/Annotator/Zoom'; const POINT_RADIUS = 16; // px @@ -25,8 +26,8 @@ export const StrongAnnotation: React.FC<{ }> = ({ annotation }) => { const dispatch = useAppDispatch(); const { spectrogram } = useAnnotationTask() - const windowWidth = useWindowWidth() - const windowHeight = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { width: windowWidth, height: windowHeight } = useSpectrogramDimensions(zoom) const isEditionAuthorized = useAppSelector(selectTaskIsEditionAuthorized) const isDrawingEnabled = useAppSelector(selectIsDrawingEnabled) diff --git a/frontend/src/features/Annotator/Axis/Axis.tsx b/frontend/src/features/Annotator/Axis/Axis.tsx index 6b86961d9..6054ba8a1 100644 --- a/frontend/src/features/Annotator/Axis/Axis.tsx +++ b/frontend/src/features/Annotator/Axis/Axis.tsx @@ -1,21 +1,19 @@ import React, { useMemo } from 'react'; import styles from './styles.module.scss'; -import { - useAnnotatorCanvasContext, - useWindowHeight, - useWindowWidth, - X_AXIS_HEIGHT, - Y_AXIS_WIDTH, -} from '@/features/Annotator/Canvas'; +import { useAnnotatorCanvasContext, X_AXIS_HEIGHT, Y_AXIS_WIDTH } from '@/features/Annotator/Canvas'; import { useAxis } from '@/components/ui'; import { formatTime, frequencyToString } from '@/service/function'; import { useFrequencyScale, useTimeScale } from './hooks' import { useAnnotationTask } from '@/api'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; +import { useAppSelector } from '@/features/App'; +import { selectZoom } from '@/features/Annotator/Zoom'; export const TimeAxis: React.FC = () => { const { spectrogram } = useAnnotationTask() const timeScale = useTimeScale() - const width = useWindowWidth() + const zoom = useAppSelector(selectZoom) + const { width } = useSpectrogramDimensions(zoom) const { xAxisCanvasRef } = useAnnotatorCanvasContext() const timeStep = useMemo(() => { @@ -30,7 +28,7 @@ export const TimeAxis: React.FC = () => { return timeScale.getSteps(timeStep.regularStep, timeStep.smallStep) }, [ timeScale, timeStep ]) useAxis({ - canvas: xAxisCanvasRef?.current, + canvasRef: xAxisCanvasRef, pixelSize: width, orientation: 'horizontal', valueToString: formatTime, @@ -46,11 +44,12 @@ export const TimeAxis: React.FC = () => { export const FrequencyAxis: React.FC = () => { const frequencyScale = useFrequencyScale() - const height = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { height } = useSpectrogramDimensions(zoom) const { yAxisCanvasRef } = useAnnotatorCanvasContext() const steps = useMemo(() => frequencyScale.getSteps(), [ frequencyScale ]) useAxis({ - canvas: yAxisCanvasRef?.current, + canvasRef: yAxisCanvasRef, pixelSize: height, orientation: 'vertical', valueToString: frequencyToString, diff --git a/frontend/src/features/Annotator/Axis/hooks.ts b/frontend/src/features/Annotator/Axis/hooks.ts index 1929105b9..c6086fba3 100644 --- a/frontend/src/features/Annotator/Axis/hooks.ts +++ b/frontend/src/features/Annotator/Axis/hooks.ts @@ -2,12 +2,14 @@ import { useMemo } from 'react'; import { LinearScaleService, MultiScaleService } from '@/components/ui'; import { useAnnotationTask } from '@/api'; import { selectAnalysis } from '@/features/Annotator/Analysis'; -import { useWindowHeight, useWindowWidth } from '@/features/Annotator/Canvas'; import { useAppSelector } from '@/features/App'; +import { selectZoom } from '@/features/Annotator/Zoom'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const useTimeScale = () => { const { spectrogram } = useAnnotationTask() - const width = useWindowWidth() + const zoom = useAppSelector(selectZoom) + const { width } = useSpectrogramDimensions(zoom) return useMemo(() => new LinearScaleService( width, @@ -21,7 +23,8 @@ export const useTimeScale = () => { export const useFrequencyScale = () => { const analysis = useAppSelector(selectAnalysis) - const height = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { height } = useSpectrogramDimensions(zoom) return useMemo(() => { const options = { diff --git a/frontend/src/features/Annotator/Canvas/TimeBar.tsx b/frontend/src/features/Annotator/Canvas/TimeBar.tsx index 314ad2ce5..e76c60d88 100644 --- a/frontend/src/features/Annotator/Canvas/TimeBar.tsx +++ b/frontend/src/features/Annotator/Canvas/TimeBar.tsx @@ -1,11 +1,14 @@ import React, { Fragment } from 'react'; import styles from './styles.module.scss'; import { useAudio } from '@/features/Audio'; -import { useWindowWidth } from './window.hooks'; +import { useAppSelector } from '@/features/App'; +import { selectZoom } from '@/features/Annotator/Zoom'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const TimeBar: React.FC = () => { const audio = useAudio(); - const width = useWindowWidth() + const zoom = useAppSelector(selectZoom) + const { width } = useSpectrogramDimensions(zoom) if (!audio.source || !audio.duration) return return ( diff --git a/frontend/src/features/Annotator/Canvas/Window.tsx b/frontend/src/features/Annotator/Canvas/Window.tsx index 53957f50f..fe4c4e377 100644 --- a/frontend/src/features/Annotator/Canvas/Window.tsx +++ b/frontend/src/features/Annotator/Canvas/Window.tsx @@ -1,4 +1,4 @@ -import React, { MouseEvent, UIEvent, useCallback, useEffect, useRef, WheelEvent } from 'react'; +import React, { MouseEvent, UIEvent, useCallback, useEffect, useRef, useState, WheelEvent } from 'react'; import styles from './styles.module.scss'; import { FrequencyAxis, TimeAxis } from '@/features/Annotator/Axis'; import { TimeBar } from './TimeBar'; @@ -8,8 +8,8 @@ import { StrongAnnotation, useTempAnnotationsEvents, } from '@/features/Annotator/Annotation'; -import { useWindowContainerWidth, useWindowHeight, useWindowWidth, Y_AXIS_WIDTH } from './window.hooks'; -import { useGetCoords, useGetFreqTime, useIsHoverCanvas, usePointer } from '@/features/Annotator/Pointer'; +import { Y_AXIS_WIDTH } from './axis-size.const'; +import { usePointer, useGetCoords, useGetFreqTime, useIsHoverCanvas } from '@/features/Annotator/Pointer'; import { selectZoom, selectZoomOrigin, useZoomIn, useZoomOut } from '@/features/Annotator/Zoom'; import { selectCanDraw } from '@/features/Annotator/UX'; import { useAudio } from '@/features/Audio'; @@ -23,14 +23,17 @@ import { selectColormap, selectContrast, selectIsColormapReversed, + selectSpectrogramMode, } from '@/features/Annotator/VisualConfiguration'; -import { selectAnalysis } from '@/features/Annotator/Analysis'; +import { selectAnalysis, selectFFT } from '@/features/Annotator/Analysis'; +import { SpectrogramDisplay } from '@/features/Spectrogram/Display'; import { AcousticFeatures } from '@/features/Annotator/AcousticFeatures'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const AnnotatorCanvasWindow: React.FC = () => { - const width = useWindowWidth() - const height = useWindowHeight() - const containerWidth = useWindowContainerWidth() + const zoom = useAppSelector(selectZoom) + const { width, height } = useSpectrogramDimensions(zoom) + const { width: containerWidth } = useSpectrogramDimensions(0) const { mainCanvasRef, windowCanvasRef } = useAnnotatorCanvasContext() const { onStartTempAnnotation } = useTempAnnotationsEvents() const getFreqTime = useGetFreqTime() @@ -43,6 +46,9 @@ export const AnnotatorCanvasWindow: React.FC = () => { const draw = useDrawCanvas() const dispatch = useAppDispatch() const pointer = usePointer() + const mode = useAppSelector(selectSpectrogramMode); + const fft = useAppSelector(selectFFT); + const [ left, setLeft ] = useState(0); const clearPointer = useCallback(() => { pointer.clearPosition() @@ -53,7 +59,8 @@ export const AnnotatorCanvasWindow: React.FC = () => { const div = event.currentTarget; const left = div.scrollWidth - div.scrollLeft - div.clientWidth; if (left <= 0) dispatch(setAllFileAsSeen()) - }, [dispatch]) + setLeft(div.scrollLeft) + }, [ dispatch, setAllFileAsSeen, setLeft ]) const onWheel = useCallback((event: WheelEvent) => { // Disable zoom if the user wants horizontal scroll @@ -67,7 +74,7 @@ export const AnnotatorCanvasWindow: React.FC = () => { else if (event.deltaY > 0) zoomOut(origin) }, [ zoomIn, zoomOut, getCoords ]) - const seekAudio = useCallback((event: MouseEvent) => { + const seekAudio = useCallback((event: MouseEvent) => { seek(getFreqTime(event)?.time ?? 0) }, [ seek, getFreqTime ]) @@ -111,7 +118,6 @@ export const AnnotatorCanvasWindow: React.FC = () => { // Zoom update - const zoom = useAppSelector(selectZoom) const zoomOrigin = useAppSelector(selectZoomOrigin) const oldZoom = useRef(1) const isHoverCanvas = useIsHoverCanvas() @@ -160,9 +166,18 @@ export const AnnotatorCanvasWindow: React.FC = () => {
e.stopPropagation() }> - } + + { const timeScale = useTimeScale() - const containerWidth = useWindowContainerWidth() + const { width: containerWidth } = useSpectrogramDimensions(0) const { mainCanvasRef, } = useAnnotatorCanvasContext() @@ -24,14 +19,13 @@ export const useFocusCanvasOnTime = () => { return useCallback((time: number) => { const left = timeScale.valueToPosition(time) - containerWidth / 2; mainCanvasRef?.current?.parentElement?.scrollTo({ left }) - }, [ timeScale, containerWidth ]) + }, [ timeScale, containerWidth, mainCanvasRef ]) } export const useDrawCanvas = () => { - const width = useWindowWidth() - const height = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { width, height } = useSpectrogramDimensions(zoom) - const drawSpectrogram = useDrawSpectrogram() const drawTempAnnotation = useDrawTempAnnotation() const applyFilter = useApplyFilter() const applyColormap = useApplyColormap() @@ -39,26 +33,25 @@ export const useDrawCanvas = () => { const { mainCanvasRef } = useAnnotatorCanvasContext() return useCallback(async () => { - const context = mainCanvasRef?.current?.getContext('2d', { alpha: false }); + const context = mainCanvasRef?.current?.getContext('2d'); if (!context) return; // Reset context.clearRect(0, 0, width, height); - applyFilter(context) - await drawSpectrogram(context) - applyColormap(context) + // applyFilter(context) + // applyColormap(context) drawTempAnnotation(context) - }, [ width, height, drawSpectrogram, applyFilter, applyColormap, drawTempAnnotation ]); + }, [ width, height, applyFilter, applyColormap, drawTempAnnotation, mainCanvasRef ]); } export const useDownloadCanvas = () => { - const height = useWindowHeight() const zoom = useAppSelector(selectZoom) + const { height } = useSpectrogramDimensions(zoom) const draw = useDrawCanvas() - const { mainCanvasRef, xAxisCanvasRef, yAxisCanvasRef } = useAnnotatorCanvasContext() + const { xAxisCanvasRef, yAxisCanvasRef } = useAnnotatorCanvasContext() return useCallback(async (filename: string) => { const link = document.createElement('a'); @@ -68,7 +61,7 @@ export const useDownloadCanvas = () => { // Get spectro images await draw() - const spectroDataURL = mainCanvasRef?.current?.toDataURL('image/png'); + const spectroDataURL = (document.getElementById('spectrogram') as HTMLCanvasElement)?.toDataURL('image/png'); if (!spectroDataURL) throw new Error('Cannot recover spectro dataURL'); draw() const spectroImg = new Image(); @@ -127,5 +120,5 @@ export const useDownloadCanvas = () => { link.target = '_blank'; link.download = filename; link.click(); - }, [ height, zoom, draw ]) + }, [ height, zoom, draw, xAxisCanvasRef, yAxisCanvasRef ]) } diff --git a/frontend/src/features/Annotator/Canvas/index.ts b/frontend/src/features/Annotator/Canvas/index.ts index f9e9d8280..b0cf7a829 100644 --- a/frontend/src/features/Annotator/Canvas/index.ts +++ b/frontend/src/features/Annotator/Canvas/index.ts @@ -1,6 +1,6 @@ export { AnnotatorCanvasContextProvider, useAnnotatorCanvasContext } from './context' export * from './hooks' -export * from './window.hooks' +export * from './axis-size.const' export * from './Window' diff --git a/frontend/src/features/Annotator/Canvas/styles.module.scss b/frontend/src/features/Annotator/Canvas/styles.module.scss index 2adfac282..77cf0aac8 100644 --- a/frontend/src/features/Annotator/Canvas/styles.module.scss +++ b/frontend/src/features/Annotator/Canvas/styles.module.scss @@ -15,6 +15,12 @@ canvas { display: block; + &.interfaction { + position: absolute; + top: 0; + left: 0; + } + &.drawable { cursor: crosshair; } diff --git a/frontend/src/features/Annotator/Canvas/window.hooks.ts b/frontend/src/features/Annotator/Canvas/window.hooks.ts deleted file mode 100644 index 3d71032bf..000000000 --- a/frontend/src/features/Annotator/Canvas/window.hooks.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useAppSelector } from '@/features/App'; -import { selectZoom } from '@/features/Annotator/Zoom'; -import { useMemo } from 'react'; - -const SPECTRO_HEIGHT: number = 512; -const SPECTRO_WIDTH: number = 1813; -export const Y_AXIS_WIDTH: number = 35; -export const X_AXIS_HEIGHT: number = 30; - - -const useWindowRatio = () => - useMemo(() => window.devicePixelRatio * (1920 / (window.screen.width * window.devicePixelRatio)), []) - -export const useWindowContainerWidth = () => { - const ratio = useWindowRatio() - return useMemo(() => SPECTRO_WIDTH / ratio, [ ratio ]) -} - -export const useWindowWidth = () => { - const zoom = useAppSelector(selectZoom) - const containerWidth = useWindowContainerWidth() - - return useMemo(() => containerWidth * zoom, [ containerWidth, zoom ]) -} - -export const useWindowHeight = () => { - const ratio = useWindowRatio() - - return useMemo(() => SPECTRO_HEIGHT / ratio, [ ratio ]) -} diff --git a/frontend/src/features/Annotator/Pointer/hooks.ts b/frontend/src/features/Annotator/Pointer/hooks.ts index fbeccf9b5..2f8725e57 100644 --- a/frontend/src/features/Annotator/Pointer/hooks.ts +++ b/frontend/src/features/Annotator/Pointer/hooks.ts @@ -1,13 +1,15 @@ import { useCallback } from 'react'; -import { useWindowHeight, useWindowWidth } from '@/features/Annotator/Canvas'; import { useFrequencyScale, useTimeScale } from '@/features/Annotator/Axis'; import { type Position, type TimeFreqPosition } from './context'; import { useAnnotatorCanvasContext } from '@/features/Annotator/Canvas/context'; import type { AnnotationNode } from '@/api'; +import { useAppSelector } from '@/features/App'; +import { selectZoom } from '@/features/Annotator/Zoom'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const useIsHoverCanvas = () => { - const width = useWindowWidth() - const height = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { width, height } = useSpectrogramDimensions(zoom) return useCallback((e: Position): boolean => { return document.elementsFromPoint(e.clientX, e.clientY).some((element: Element): boolean => { @@ -33,7 +35,7 @@ export const useGetCoords = () => { y: Math.min(Math.max(0, y), bounds.height), } } else return { x, y } - }, []) + }, [mainCanvasRef]) } export const useGetFreqTime = () => { diff --git a/frontend/src/features/Annotator/Spectrogram/DownloadButton.tsx b/frontend/src/features/Annotator/Spectrogram/DownloadButton.tsx index 5cdae15d7..826436130 100644 --- a/frontend/src/features/Annotator/Spectrogram/DownloadButton.tsx +++ b/frontend/src/features/Annotator/Spectrogram/DownloadButton.tsx @@ -3,31 +3,31 @@ import { IonButton, IonIcon, IonSpinner } from '@ionic/react'; import { downloadOutline } from 'ionicons/icons/index.js'; import { useAnnotationTask, useCurrentUser } from '@/api'; import { useDownloadCanvas } from '@/features/Annotator/Canvas'; -import { selectZoom } from '@/features/Annotator/Zoom'; +import { selectDisplayZoom } from '@/features/Annotator/Zoom'; import { useAppSelector } from '@/features/App'; export const SpectrogramDownloadButton: React.FC = () => { - const zoom = useAppSelector(selectZoom) - const { spectrogram } = useAnnotationTask() - const { user } = useCurrentUser(); - const download = useDownloadCanvas(); - const [ isLoading, setIsLoading ] = useState(false); + const displayZoom = useAppSelector(selectDisplayZoom) + const { spectrogram } = useAnnotationTask() + const { user } = useCurrentUser(); + const download = useDownloadCanvas(); + const [ isLoading, setIsLoading ] = useState(false); - const downloadSpectrogram = useCallback(async () => { - if (!spectrogram) return; - setIsLoading(true); - try { - await download(`${ spectrogram.filename }-x${ zoom }.png`) - } finally { - setIsLoading(false); - } - }, [ download, spectrogram, zoom ]) + const downloadSpectrogram = useCallback(async () => { + if (!spectrogram) return; + setIsLoading(true); + try { + await download(`${ spectrogram.filename }-x${ displayZoom }.png`) + } finally { + setIsLoading(false); + } + }, [ download, spectrogram, displayZoom ]) - if (!spectrogram || !user?.isAdmin) return - return - - Download spectrogram (zoom x{ zoom }) - { isLoading && } - + if (!spectrogram || !user?.isAdmin) return + return + + Download spectrogram (zoom x{ displayZoom }) + { isLoading && } + } diff --git a/frontend/src/features/Annotator/Spectrogram/hooks.ts b/frontend/src/features/Annotator/Spectrogram/hooks.ts deleted file mode 100644 index 93ba2cecd..000000000 --- a/frontend/src/features/Annotator/Spectrogram/hooks.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useCallback, useRef } from 'react'; -import { selectZoom } from '@/features/Annotator/Zoom'; -import { selectAnalysis } from '@/features/Annotator/Analysis'; -import { useAnnotationTask } from '@/api'; -import { useToast } from '@/components/ui'; -import { useWindowHeight } from '@/features/Annotator/Canvas'; -import { useTimeScale } from '@/features/Annotator/Axis'; -import { useAppSelector } from '@/features/App'; - -export const useDrawSpectrogram = () => { - const analysis = useAppSelector(selectAnalysis) - const zoom = useAppSelector(selectZoom) - const { spectrogram } = useAnnotationTask() - const height = useWindowHeight() - const timeScale = useTimeScale() - const toast = useToast() - const images = useRef>>(new Map); - const failedImagesSources = useRef([]) - - const areAllImagesLoaded = useCallback((): boolean => { - return images.current.get(zoom)?.filter(i => !!i).length === zoom - }, [ zoom ]) - - const loadImages = useCallback(async () => { - if (!analysis || !spectrogram?.path) { - images.current = new Map(); - return; - } - if (areAllImagesLoaded()) return; - - const filename = spectrogram.filename - return Promise.all( - Array.from(new Array(zoom)).map(async (_, index) => { - let src = spectrogram?.path; - if (!src) return; - if (analysis.legacy) { - src = `${ src.split(filename)[0] }${ filename }_${ zoom }_${ index }${ src.split(filename)[1] }` - } - if (failedImagesSources.current.includes(src)) return; - console.info(`Will load for zoom ${ zoom }, image ${ index }`) - const image = new Image(); - image.src = src; - return await new Promise((resolve) => { - image.onload = () => { - console.info(`Image loaded: ${ image.src }`) - resolve(image); - } - image.onerror = error => { - failedImagesSources.current.push(src) - toast.raiseError({ - message: `Cannot load spectrogram image with source: ${ image.src }`, - error, - }) - resolve(undefined); - } - }) - }), - ).then(loadedImages => { - images.current.set(zoom, loadedImages) - }) - }, [ analysis, zoom, failedImagesSources, areAllImagesLoaded, spectrogram, analysis ]) - - return useCallback(async (context: CanvasRenderingContext2D) => { - if (!areAllImagesLoaded()) await loadImages(); - if (!areAllImagesLoaded()) return; - - const currentImages = images.current.get(zoom) - if (!currentImages || !spectrogram) return; - for (const i in currentImages) { - const index: number | undefined = i ? +i : undefined; - if (index === undefined) continue; - const start = index * spectrogram.duration / zoom; - const end = (index + 1) * spectrogram.duration / zoom; - const image = currentImages[index]; - if (!image) continue - context.drawImage( - image, - timeScale.valueToPosition(start), - 0, - Math.floor(timeScale.valuesToPositionRange(start, end)), - height, - ) - } - }, [ images, zoom, spectrogram, timeScale, height, areAllImagesLoaded, loadImages ]) -} diff --git a/frontend/src/features/Annotator/Spectrogram/index.ts b/frontend/src/features/Annotator/Spectrogram/index.ts index 9df95dede..e6afa7e31 100644 --- a/frontend/src/features/Annotator/Spectrogram/index.ts +++ b/frontend/src/features/Annotator/Spectrogram/index.ts @@ -1,4 +1,2 @@ -export * from './hooks' - export * from './Info' export * from './DownloadButton' diff --git a/frontend/src/features/Annotator/VisualConfiguration/SpectrogramModeSelect.tsx b/frontend/src/features/Annotator/VisualConfiguration/SpectrogramModeSelect.tsx new file mode 100644 index 000000000..95f6c9574 --- /dev/null +++ b/frontend/src/features/Annotator/VisualConfiguration/SpectrogramModeSelect.tsx @@ -0,0 +1,27 @@ +import React, { useCallback } from 'react'; +import { Select } from '@/components/form'; +import { useAppDispatch, useAppSelector } from '@/features/App'; +import { selectSpectrogramMode } from './selectors'; +import type { SpectrogramMode } from '@/features/Spectrogram/Display'; +import { setSpectrogramMode } from '@/features/Annotator/VisualConfiguration/slice'; + + +export const SpectrogramModeSelect: React.FC = () => { + const mode = useAppSelector(selectSpectrogramMode); + const dispatch = useAppDispatch() + + const set = useCallback((mode?: string | number) => { + dispatch(setSpectrogramMode(mode as SpectrogramMode)) + }, [ dispatch ]) + + return