diff --git a/network/static/network/calendar-to-coords.js b/network/static/network/calendar-to-coords.js new file mode 100644 index 0000000..9ea2eff --- /dev/null +++ b/network/static/network/calendar-to-coords.js @@ -0,0 +1,70 @@ +function dateToCoordinates(isoDate, minYear = 1500, maxYear = 1800) { + // Parse the ISO date + const date = new Date(isoDate); + if (isNaN(date)) { + throw new Error("Invalid date format"); + } + + // Extract year, month, and day + const year = date.getUTCFullYear(); + const month = date.getUTCMonth() + 1; // Months are 0-based + const day = date.getUTCDate(); + + // Map year to latitude (-90 to 90) with larger gaps between years + const yearFactor = (year - minYear) / (maxYear - minYear); + const latitude = yearFactor * 180 - 90; + + // Map month and day to longitude (-180 to 180) + const maxMonth = 12; + const maxDay = 31; // Approximation for simplicity + const monthFactor = (month - 1) / (maxMonth - 1); + const dayFactor = (day - 1) / (maxDay - 1); + const longitude = ((monthFactor + dayFactor) / 2) * 360 - 180; + + return { lat: latitude, lng: longitude }; +} + +function scaleToRange( + x, + originalMin = 1, + originalMax = 10, + targetMin = 0, + targetMax = 250 +) { + return ( + Math.round( + ((x - originalMin) * (targetMax - targetMin)) / + (originalMax - originalMin) + ) + targetMin + ); +} + +function mapValueToColor(value) { + if (value < 1 || value > 10) { + return [0, 0, 0]; + } + + // Normalize the value to a range of 0 to 1 + const normalized = (value - 1) / 9; + + // Define the gradient colors (red -> yellow -> green) + const startColor = [255, 0, 0]; // Red + const midColor = [255, 255, 0]; // Yellow + const endColor = [0, 255, 0]; // Green + + let color; + if (normalized <= 0.5) { + // Interpolate between startColor and midColor + const t = normalized * 2; // Scale to [0, 1] + color = startColor.map((start, i) => + Math.round(start + t * (midColor[i] - start)) + ); + } else { + // Interpolate between midColor and endColor + const t = (normalized - 0.5) * 2; // Scale to [0, 1] + color = midColor.map((mid, i) => Math.round(mid + t * (endColor[i] - mid))); + } + + // Convert to RGB format + return [color[0], color[1], color[2]]; +} diff --git a/network/static/network/calender.js b/network/static/network/calender.js new file mode 100644 index 0000000..acba6b8 --- /dev/null +++ b/network/static/network/calender.js @@ -0,0 +1,102 @@ +const url = document.getElementById("url").textContent; +console.log("fetching data"); +fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error("Calender data response was not ok"); + } + return response.json(); + }) + .then((data) => { + const legendDiv = document.getElementById("legend"); + const dl = document.createElement("dl"); // Create the
element + + data.metadata.query_params.forEach((param) => { + for (const [key, value] of Object.entries(param)) { + const dt = document.createElement("dt"); // Create the
element + dt.textContent = key; + const dd = document.createElement("dd"); // Create the
element + dd.textContent = value; + + dl.appendChild(dt); + dl.appendChild(dd); + } + }); + + // Ensure each event has a label property + const validEvents = data.events + .filter((event) => event.latitude && event.longitude) + .map((event) => ({ + ...event, + label: event.label || "Unknown Event", // Default to 'Unknown Event' if label is missing + })); + + console.log(validEvents); + const deckgl = new deck.DeckGL({ + container: "map", + initialViewState: { + altitude: 1.5, + height: 700, + longitude: 80, + latitude: 35, + zoom: 2, + pitch: 60, + // bearing: -1.7 + }, + controller: true, + // onViewStateChange: ({ viewState }) => { + // console.log("Current view state:", viewState); + // }, + layers: [ + new deck.HexagonLayer({ + data: validEvents, + getPosition: (d) => [d.longitude, d.latitude], + radius: 50000, + elevationScale: 4000, + elevationRange: [0, 50], + extruded: true, + pickable: true, + onHover: ({ object, x, y }) => { + const tooltip = document.getElementById("tooltip"); + if (object) { + const eventLabels = object.points.map((p) => p.source.label); + const curDate = object.points[0].source.date; + const listItems = eventLabels + .map((label) => `
  • ${label}
  • `) + .join(""); + tooltip.style.display = "block"; + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + tooltip.innerHTML = `${curDate}`; + } else { + tooltip.style.display = "none"; + } + }, + }), + ], + }); + + legendDiv.appendChild(dl); + }) + .catch((error) => { + console.error("Something went wrong:", error); + }); + +// Add this CSS for the tooltip +const style = document.createElement("style"); +style.innerHTML = ` + #tooltip { + position: absolute; + background: white; + padding: 5px; + border: 1px solid black; + display: none; + pointer-events: none; + } +`; +document.head.appendChild(style); + +// Add this HTML for the tooltip +const tooltip = document.createElement("div"); +tooltip.id = "tooltip"; +document.body.appendChild(tooltip); diff --git a/network/static/network/map.js b/network/static/network/map.js new file mode 100644 index 0000000..595ac9d --- /dev/null +++ b/network/static/network/map.js @@ -0,0 +1,105 @@ +const url = document.getElementById("url").textContent; +console.log("fetching data"); +fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error("Geojson response was not ok"); + } + return response.json(); + }) + .then((data) => { + var map = L.map("map"); + const legendDiv = document.getElementById("legend"); + const dl = document.createElement("dl"); // Create the
    element + + data.metadata.query_params.forEach((param) => { + for (const [key, value] of Object.entries(param)) { + const dt = document.createElement("dt"); // Create the
    element + dt.textContent = key; + const dd = document.createElement("dd"); // Create the
    element + dd.textContent = value; + + dl.appendChild(dt); // Append
    to
    + dl.appendChild(dd); // Append
    to
    + } + }); + + legendDiv.appendChild(dl); + var OSMBaseLayer = L.tileLayer( + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + { + maxZoom: 19, + attribution: + '© OpenStreetMap', + } + ).addTo(map); + + var CartoDB_PositronNoLabels = L.tileLayer( + "https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png", + { + attribution: + '© OpenStreetMap contributors © CARTO', + subdomains: "abcd", + maxZoom: 20, + } + ); + + var CartoDB_DarkMatterNoLabels = L.tileLayer( + "https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png", + { + attribution: + '© OpenStreetMap contributors © CARTO', + subdomains: "abcd", + maxZoom: 20, + } + ); + + const markers = L.markerClusterGroup(); + const geojsonLayer = L.geoJSON(data, { + onEachFeature: function (feature, layer) { + layer.bindPopup(feature.properties.label); + }, + pointToLayer: function (feature, latlng) { + return L.marker(latlng); + }, + }); + markers.addTo(map); + geojsonLayer.eachLayer((layer) => markers.addLayer(layer)); + + var heatData = []; + L.geoJSON(data, { + onEachFeature: function (feature, layer) { + if (feature.geometry.type === "Point") { + var lat = feature.geometry.coordinates[1]; + var lng = feature.geometry.coordinates[0]; + heatData.push([lat, lng]); + } + }, + }); + + // Create the heatmap layer + var heatmapLayer = L.heatLayer(heatData, { + radius: 25, + blur: 10, + maxZoom: 17, + max: 0.7, + gradient: { 0: "white", 0.5: "lime", 1: "red" }, + }); + + var baseMaps = { + "Base Layer": OSMBaseLayer, + "CartoDB hell": CartoDB_PositronNoLabels, + "CartoDB dunkel": CartoDB_DarkMatterNoLabels, + }; + + const overlayMaps = { + "Marker Cluster": markers, + Heatmap: heatmapLayer, + }; + + L.control.layers(baseMaps, overlayMaps, { collapsed: false }).addTo(map); + map.fitBounds(geojsonLayer.getBounds()); + }) + .catch((error) => { + console.error("Something went wrong:", error); + }); diff --git a/network/templates/network/calender.html b/network/templates/network/calender.html new file mode 100644 index 0000000..77b6d5c --- /dev/null +++ b/network/templates/network/calender.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% load static %} +{% block title %}Map{% endblock %} +{% block scriptHeader %} +{% endblock %} +{% block content %} + + +
    +

    Kalender

    +
    +
    +
    +

    gewählte Filterparameter

    +
    +
    +
    + + + +{% endblock %} \ No newline at end of file diff --git a/network/templates/network/list_view.html b/network/templates/network/list_view.html index f263f26..7787346 100644 --- a/network/templates/network/list_view.html +++ b/network/templates/network/list_view.html @@ -16,6 +16,7 @@

    CSV JSON Als Netzwerk + Als Kalender GeoJson Karte diff --git a/network/templates/network/map.html b/network/templates/network/map.html index f45d09e..859378b 100644 --- a/network/templates/network/map.html +++ b/network/templates/network/map.html @@ -22,101 +22,5 @@

    gewählte Filterparameter

    - + {% endblock %} \ No newline at end of file diff --git a/network/tests.py b/network/tests.py index 6bfa4ef..25426ab 100644 --- a/network/tests.py +++ b/network/tests.py @@ -24,6 +24,26 @@ def test_01_network(self): response = client.get(url) self.assertEqual(response.status_code, 200) + def test_01a_network_data(self): + url = reverse("network:data") + response = client.get(url) + self.assertEqual(response.status_code, 200) + + def test_01b_network_data(self): + url = f'{reverse("network:data")}?format=hansi' + response = client.get(url) + self.assertEqual(response.status_code, 200) + + def test_01c_network_data(self): + url = f'{reverse("network:data")}?format=json' + response = client.get(url) + self.assertEqual(response.status_code, 200) + + def test_01d_network_data(self): + url = f'{reverse("network:data")}?format=cosmograph' + response = client.get(url) + self.assertEqual(response.status_code, 200) + def test_02_edge_list_view(self): url = reverse("network:edges_browse") response = client.get(url) @@ -38,3 +58,8 @@ def test_04_geojson_view(self): url = reverse("network:geojson") response = client.get(url) self.assertEqual(response.status_code, 200) + + def test_05_calendar_data(self): + url = reverse("network:calender_data") + response = client.get(url) + self.assertEqual(response.status_code, 200) diff --git a/network/urls.py b/network/urls.py index 24d3ac1..f0eceb8 100644 --- a/network/urls.py +++ b/network/urls.py @@ -3,7 +3,9 @@ EdgeListViews, network_data, NetworkView, + CalenderView, edges_as_geojson, + edges_as_calender, MapView, ) @@ -14,5 +16,7 @@ path("network-data/", network_data, name="data"), path("network/", NetworkView.as_view(), name="network"), path("geojson-data/", edges_as_geojson, name="geojson"), + path("calender-data/", edges_as_calender, name="calender_data"), + path("calender/", CalenderView.as_view(), name="calender"), path("map/", MapView.as_view(), name="map"), ] diff --git a/network/utils.py b/network/utils.py index a2aa805..82505b2 100644 --- a/network/utils.py +++ b/network/utils.py @@ -1,4 +1,5 @@ import pandas as pd +from datetime import datetime, date def df_to_geojson_vect( @@ -35,3 +36,55 @@ def get_coords(row): return (row["target_lat"], row["target_lng"]) else: return row["source_lat"], row["source_lng"] + + +def iso_to_lat_long(iso_date, start_date="1700-01-01", end_date="1990-12-31"): + """ + Maps an ISO date string or datetime.date to latitude and longitude, ensuring + earlier dates are more south (latitude) and earlier days within a year are more west (longitude). + + Args: + iso_date (str | datetime.date): An ISO-formatted date string (e.g., "2023-01-01") + or a datetime.date object. + start_date (str): Start of the date range in ISO format (default: "1900-01-01"). + end_date (str): End of the date range in ISO format (default: "2100-12-31"). + + Returns: + tuple: A tuple containing latitude and longitude (both as floats). + """ + try: + # Ensure iso_date is a datetime.date object + if isinstance(iso_date, str): + date_obj = datetime.strptime(iso_date, "%Y-%m-%d").date() + elif isinstance(iso_date, date): + date_obj = iso_date + else: + raise ValueError("Invalid input type. Must be a string or datetime.date.") + + # Convert start_date and end_date to datetime.date objects + start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date() + end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").date() + + # Ensure date_obj is within range + if not (start_date_obj <= date_obj <= end_date_obj): + raise ValueError("Date is out of the specified range.") + + # Latitude: Based on the position of the date within the range (0–90) + total_days = (end_date_obj - start_date_obj).days + date_position = (date_obj - start_date_obj).days / total_days + lat = 90 * date_position + + # Longitude: Inverted position of the day within the year (0–180) + day_of_year = date_obj.timetuple().tm_yday + days_in_year = ( + date(datetime(date_obj.year, 12, 31).year, 12, 31) + - date(datetime(date_obj.year, 1, 1).year, 1, 1) + ).days + 1 + day_position = ( + day_of_year - 1 + ) / days_in_year # Normalize day position in the year + lon = 180 * day_position + + return lat, lon + except Exception as e: + raise ValueError(f"Invalid input: {iso_date}") from e diff --git a/network/views.py b/network/views.py index bb3c77b..65c2cd8 100644 --- a/network/views.py +++ b/network/views.py @@ -14,7 +14,7 @@ from network.forms import EdgeFilterFormHelper from network.models import Edge from network.tables import EdgeTable -from network.utils import get_coords, df_to_geojson_vect +from network.utils import get_coords, df_to_geojson_vect, iso_to_lat_long class NetworkView(TemplateView): @@ -42,6 +42,10 @@ class MapView(TemplateView): template_name = "network/map.html" +class CalenderView(TemplateView): + template_name = "network/calender.html" + + class EdgeListViews(GenericListView): model = Edge filter_class = EdgeListFilter @@ -57,6 +61,51 @@ class EdgeListViews(GenericListView): template_name = "network/list_view.html" +def edges_as_calender(request): + query_params = request.GET + queryset = ( + Edge.objects.filter() + .exclude(start_date__isnull=True) + .exclude(start_date__lte="0001-01-01") + ) + values_list = [x.name for x in Edge._meta.get_fields()] + qs = EdgeListFilter(request.GET, queryset=queryset).qs + items = list(qs.values_list(*values_list)) + df = pd.DataFrame(list(items), columns=values_list) + start_date = str(df["start_date"].min()) + end_date = str(df["start_date"].max()) + df["latitude"], df["longitude"] = zip( + *df["start_date"].map( + lambda date: iso_to_lat_long(date, start_date=start_date, end_date=end_date) + ) + ) + df["label"] = df[["source_label", "edge_label", "target_label"]].agg( + " ".join, axis=1 + ) + df = df.sort_values(by="start_date") + items = df.apply( + lambda row: { + "date": str(row["start_date"]), + "label": row["label"], + "edge_label": row["edge_label"], + "kind": row["edge_kind"], + "latitude": row["latitude"], + "longitude": row["longitude"], + "id": row["edge_id"], + }, + axis=1, + ).tolist() + data = {} + data = {"metadata": {}, "events": items} + data["metadata"] = {"number of objects": len(items)} + data["metadata"]["query_params"] = [ + {key: value} for key, value in query_params.items() + ] + data["metadata"]["start_date"] = str(df["start_date"].min()) + data["metadata"]["end_date"] = str(df["start_date"].max()) + return JsonResponse(data=data, json_dumps_params={"ensure_ascii": False}) + + def edges_as_geojson(request): query_params = request.GET values_list = [x.name for x in Edge._meta.get_fields()] diff --git a/notebooks/issue__282_emt.ipynb b/notebooks/issue__282_emt.ipynb new file mode 100644 index 0000000..5177429 --- /dev/null +++ b/notebooks/issue__282_emt.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "a7cbcfd0-b9f4-4897-9cf5-a8cec08683da", + "metadata": {}, + "outputs": [], + "source": [ + "# run against production 2024-12-19\n", + "from acdh_tei_pyutils.tei import TeiReader\n", + "from acdh_tei_pyutils.utils import get_xmlid\n", + "from acdh_xml_pyutils.xml import NSMAP\n", + "from normdata.utils import import_from_normdata\n", + "from tqdm import tqdm" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5babd6f6-4da8-4ad5-9ea3-955d77c768bd", + "metadata": {}, + "outputs": [], + "source": [ + "doc = TeiReader(\"https://emt.acdh-dev.oeaw.ac.at/listperson.xml\")\n", + "domain = \"kaiserin-eleonora\"\n", + "col, _ = Collection.objects.get_or_create(name=domain)\n", + "col" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c769236-3a4c-4d50-bf26-13735f073fdd", + "metadata": {}, + "outputs": [], + "source": [ + "broken_gnd = []\n", + "for x in tqdm(doc.any_xpath(\".//tei:person[@xml:id and ./tei:idno[@type='GND']]\")):\n", + " gnd_uri = x.xpath(\"./tei:idno[@type='GND']/text()\", namespaces=NSMAP)[0]\n", + " xmlid = get_xmlid(x)\n", + " uri = \"https://kaiserin-eleonora.oeaw.ac.at/{}.html\".format(xmlid)\n", + " entity = import_from_normdata(gnd_uri, \"person\")\n", + " if entity:\n", + " pmb_uri, _ = Uri.objects.get_or_create(uri=uri, domain=domain)\n", + " pmb_uri.entity = entity\n", + " pmb_uri.save()\n", + " entity.collection.add(col)\n", + " else:\n", + " broken_gnd.append(gnd_uri)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4fb4dab6-7057-44d7-b939-3ff96473c442", + "metadata": {}, + "outputs": [], + "source": [ + "print(broken_gnd)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8dfd3fe3-21d3-4b8c-aee1-0e375cb5c607", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Django Shell-Plus", + "language": "python", + "name": "django_extensions" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pmb/settings.py b/pmb/settings.py index ba3ee85..be89df6 100644 --- a/pmb/settings.py +++ b/pmb/settings.py @@ -160,11 +160,6 @@ USE_THOUSAND_SEPARATOR = False -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.2/howto/static-files/ - -STATIC_URL = "/static/" - # Default primary key field type # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field