From 3a6ead8fde673af84fa4edce8711f94b920f427a Mon Sep 17 00:00:00 2001 From: kempersc Date: Tue, 9 Dec 2025 16:58:41 +0100 Subject: [PATCH] feat: Add legal form filtering rule for CustodianName - Introduced LEGAL-FORM-FILTER rule to standardize CustodianName by removing legal form designations. - Documented rationale, examples, and implementation guidelines for the filtering process. docs: Create README for value standardization rules - Established a comprehensive README outlining various value standardization rules applicable to Heritage Custodian classes. - Categorized rules into Name Standardization, Geographic Standardization, Web Observation, and Schema Evolution. feat: Implement transliteration standards for non-Latin scripts - Added TRANSLIT-ISO rule to ensure GHCID abbreviations are generated from emic names using ISO standards for transliteration. - Included detailed guidelines for various scripts and languages, along with implementation examples. feat: Define XPath provenance rules for web observations - Created XPATH-PROVENANCE rule mandating XPath pointers for claims extracted from web sources. - Established a workflow for archiving websites and verifying claims against archived HTML. chore: Update records lifecycle diagram - Generated a new Mermaid diagram illustrating the records lifecycle for heritage custodians. - Included phases for active records, inactive archives, and processed heritage collections with key relationships and classifications. --- ...eye_filmmuseum_google_maps_playwright.json | 3413 ++++++++++++++++- .../GLAMORCUBEPSXHFN/hyponyms_curated.yaml | 5 + frontend/package-lock.json | 392 +- frontend/package.json | 5 +- .../linkml/01_custodian_name_modular.yaml | 4 +- .../schemas/20251121/linkml/manifest.json | 2 +- .../modules/classes/CompanyArchives.yaml | 199 +- .../linkml/modules/classes/Conservatoria.yaml | 52 +- .../modules/classes/CountyRecordOffice.yaml | 98 +- .../modules/classes/CurrentArchive.yaml | 47 + .../modules/classes/CustodianArchive.yaml | 52 + .../linkml/modules/classes/CustodianName.yaml | 6 +- .../linkml/modules/classes/Department.yaml | 19 + .../linkml/modules/classes/WebClaim.yaml | 2 +- .../linkml/rules/ABBREVIATION_RULES.md | 303 ++ .../20251121/linkml/rules/ENUM_TO_CLASS.md | 237 ++ .../linkml/rules/GEONAMES_SETTLEMENT.md | 436 +++ .../linkml/rules/LEGAL_FORM_FILTER.md | 346 ++ .../schemas/20251121/linkml/rules/README.md | 156 + .../20251121/linkml/rules/TRANSLITERATION.md | 337 ++ .../20251121/linkml/rules/XPATH_PROVENANCE.md | 210 + frontend/src/App.tsx | 5 + .../database/EmbeddingProjector.tsx | 1404 +++++++ .../src/components/database/QdrantPanel.tsx | 295 +- frontend/src/components/database/index.ts | 2 + .../components/gesprek/GesprekBarChart.tsx | 434 +++ .../src/components/gesprek/GesprekGeoMap.tsx | 433 +++ .../gesprek/GesprekNetworkGraph.tsx | 549 +++ .../components/gesprek/GesprekTimeline.tsx | 497 +++ frontend/src/components/gesprek/index.ts | 18 + frontend/src/components/layout/Navigation.tsx | 9 + .../components/query/OntologyVisualizer.tsx | 34 +- .../components/uml/CustodianTypeIndicator.tsx | 477 +++ frontend/src/contexts/LanguageContext.tsx | 1 + frontend/src/hooks/useMultiDatabaseRAG.ts | 657 ++++ frontend/src/hooks/useWerkgebiedMapLibre.ts | 15 +- frontend/src/lib/custodian-types.ts | 386 ++ frontend/src/lib/schema-custodian-mapping.ts | 237 ++ frontend/src/pages/Database.css | 727 ++++ frontend/src/pages/GesprekPage.css | 1370 +++++++ frontend/src/pages/GesprekPage.tsx | 1075 ++++++ frontend/src/pages/LinkMLViewerPage.css | 36 + frontend/src/pages/LinkMLViewerPage.tsx | 82 +- frontend/src/pages/NDEMapPageMapLibre.tsx | 18 +- frontend/src/pages/ProjectPlanPage.tsx | 2 +- frontend/src/vite-env.d.ts | 560 +++ frontend/vite.config.ts | 18 + .../linkml/01_custodian_name_modular.yaml | 4 +- .../linkml/modules/classes/Conservatoria.yaml | 52 +- .../modules/classes/CountyRecordOffice.yaml | 98 +- .../modules/classes/CurrentArchive.yaml | 47 + .../modules/classes/CustodianArchive.yaml | 52 + .../linkml/modules/classes/CustodianName.yaml | 6 +- .../linkml/modules/classes/WebClaim.yaml | 2 +- .../linkml/rules/ABBREVIATION_RULES.md | 303 ++ .../20251121/linkml/rules/ENUM_TO_CLASS.md | 237 ++ .../linkml/rules/GEONAMES_SETTLEMENT.md | 436 +++ .../linkml/rules/LEGAL_FORM_FILTER.md | 346 ++ schemas/20251121/linkml/rules/README.md | 156 + .../20251121/linkml/rules/TRANSLITERATION.md | 337 ++ .../20251121/linkml/rules/XPATH_PROVENANCE.md | 210 + .../records_lifecycle_20251209_131205.mmd | 124 + scripts/geocode_missing_from_geonames.py | 388 ++ scripts/load_custodians_to_ducklake_v3.py | 23 +- 64 files changed, 18017 insertions(+), 466 deletions(-) create mode 100644 frontend/public/schemas/20251121/linkml/rules/ABBREVIATION_RULES.md create mode 100644 frontend/public/schemas/20251121/linkml/rules/ENUM_TO_CLASS.md create mode 100644 frontend/public/schemas/20251121/linkml/rules/GEONAMES_SETTLEMENT.md create mode 100644 frontend/public/schemas/20251121/linkml/rules/LEGAL_FORM_FILTER.md create mode 100644 frontend/public/schemas/20251121/linkml/rules/README.md create mode 100644 frontend/public/schemas/20251121/linkml/rules/TRANSLITERATION.md create mode 100644 frontend/public/schemas/20251121/linkml/rules/XPATH_PROVENANCE.md create mode 100644 frontend/src/components/database/EmbeddingProjector.tsx create mode 100644 frontend/src/components/gesprek/GesprekBarChart.tsx create mode 100644 frontend/src/components/gesprek/GesprekGeoMap.tsx create mode 100644 frontend/src/components/gesprek/GesprekNetworkGraph.tsx create mode 100644 frontend/src/components/gesprek/GesprekTimeline.tsx create mode 100644 frontend/src/components/gesprek/index.ts create mode 100644 frontend/src/components/uml/CustodianTypeIndicator.tsx create mode 100644 frontend/src/hooks/useMultiDatabaseRAG.ts create mode 100644 frontend/src/lib/custodian-types.ts create mode 100644 frontend/src/lib/schema-custodian-mapping.ts create mode 100644 frontend/src/pages/GesprekPage.css create mode 100644 frontend/src/pages/GesprekPage.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 schemas/20251121/linkml/rules/ABBREVIATION_RULES.md create mode 100644 schemas/20251121/linkml/rules/ENUM_TO_CLASS.md create mode 100644 schemas/20251121/linkml/rules/GEONAMES_SETTLEMENT.md create mode 100644 schemas/20251121/linkml/rules/LEGAL_FORM_FILTER.md create mode 100644 schemas/20251121/linkml/rules/README.md create mode 100644 schemas/20251121/linkml/rules/TRANSLITERATION.md create mode 100644 schemas/20251121/linkml/rules/XPATH_PROVENANCE.md create mode 100644 schemas/20251121/uml/mermaid/records_lifecycle_20251209_131205.mmd create mode 100644 scripts/geocode_missing_from_geonames.py diff --git a/data/google_maps_enrichment/NL-NH-AMS-U-EFM-eye_filmmuseum_google_maps_playwright.json b/data/google_maps_enrichment/NL-NH-AMS-U-EFM-eye_filmmuseum_google_maps_playwright.json index 026cceaca3..252aa267ed 100644 --- a/data/google_maps_enrichment/NL-NH-AMS-U-EFM-eye_filmmuseum_google_maps_playwright.json +++ b/data/google_maps_enrichment/NL-NH-AMS-U-EFM-eye_filmmuseum_google_maps_playwright.json @@ -1,7 +1,7 @@ { "ghcid": "NL-NH-AMS-U-EFM-eye_filmmuseum", "name": "Eye Filmmuseum", - "scrape_timestamp": "2025-12-09T09:50:57.210350+00:00", + "scrape_timestamp": "2025-12-09T15:27:33.410292+00:00", "scrape_method": "playwright", "source_url": "https://www.google.com/maps/place/Eye+Filmmuseum/@52.384348,4.901276", "address": "IJpromenade 1, 1031 KT Amsterdam", @@ -67,85 +67,3388 @@ "typical_busyness_percent": 25, "individual_reviews": [ { - "reviewer_name": "Артур Кульбачный", "rating": 5, - "relative_time": "2 months ago", - "text_preview": "A very beautiful futuristic building located on the main canal of Amsterdam...", - "text_full": null, - "language_detected": "en" + "relative_time": "een week geleden", + "text_full": "Cinemini!! De eerste keer naar de bioscoop, Dit was zo ontzettend leuk en gezellig. Eerst een filmpje (De Gruffalo) die toch op sommige momenten toch nog best spannend was voor onze mini. Daarna konden ze nog ff spelen en kleuren met een glaasje ranja.\n\nHier gaan we zeker weten nog een keer heen!", + "is_truncated": false, + "reviewer_name": "Joanice", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVEBi5ZutsbCaV_CVXKFQy8TdNNoAyQ1q4pO0wr44rEqJAgqRs=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vtWkO5OiKInCReEyGt4DPgDrh_530a8ifJM-K6A5okyi5hmhcGDy_WAcbgOl5BC0dZHvd5jaai25TbO9qRlZgjpp7UTw8PdTH2n91_vB5dal4HO5LRIMqiPjVSaucmr1M4zk2OI70OI7c=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tEEbCHx7wnAxqFohOiCDZVjsZk2we0ZekPOEEPIYvFaVEWEUE85bMkDPOI5CNiuuMOC6ZmB-5RQhIlyuO8jlbrARG7owGYl2HdlyCDbOc620OyJXmjIzRuiF4RtNZErnf7WCfk_66y1rlt=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tZVGEDmqH_TZsrkA6_k_mOFDCtO9wfOgzkqcv0onx302nIaDLXp4FQEnQmwbAMdhc2oL2hBPilGwItuWObhqcN4MSfkHrT4ft_NFKaeD-uxzWU-XLqOUwA9wd76Twz8tJxFqPQNMZ3PFwt=w300-h225-p" + ] }, { - "reviewer_name": "Monika Lošťáková", "rating": 5, - "relative_time": "a week ago", - "text_preview": "Love this place. Beautiful building with a nice view...", - "text_full": null, - "language_detected": "en" + "relative_time": "een week geleden", + "text_full": "Leuke plaats. Prachtig pand. Leuk om.gids.iets te laten vertellen. Je kunt er naar de film en wat drinken. Prachtig uitzicht.", + "is_truncated": false, + "reviewer_name": "Fleur Floris", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWzsKINU2T-MTQuFm2qTi5289Rf5yqsnIsgDtyqSYUaKd0r0shJZQ=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t4OT8COuI9w4au8eRCAnY6zJAmTujW0Ttv9Zwmcd2SJyhX0Gy68NfaSnS6bNercCp2ne4V7ehemgIyf2D1Ng2vmIXogaWY8oWJOcTsu5Qm_dZaF0xl-AEx9PEjyEkzGG30kURuVvhru8D6=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uthfmW56-tGlxj_aKqOD6wbgHCIk4WbvWGHWxAsgnRIWg8-dnvCIssPR3Y1cw_C_oar0nvVLP7m6DZQSoc-393oFdDFWRwngQe2hprOcqSz71uPX4fRTiaXuPHgLR_KxFk4Iw6BqciTdys=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sLs1AUUYpoBJf-JjNEcNTXHmGMgWOGqjDaG729lgwgDOpvebRsqCxv_UmzgSo7aPRfUwoRYAM9AlbbOlO6l2QIurn5xzLXK7BtNmOv_OlfppoJXp_Vn_m9_tTF5QkEdOOsLRa-FmLI8B_y=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sPK3rVIF6OGc9ifEh57lKv4PeuR1NbNx2uaKLO1fEYv3ert__eulOt0SyfSAHeEGPNmZiOCUrFfV_48Ts7C0-oFbFRaSpw_wQJKTVSYtO2YtXmGJXYhf7tB-oDdZTOgCqMXeF_gooXETA=w300-h225-p" + ] }, { - "reviewer_name": "Charbel Gh", "rating": 5, - "relative_time": "4 weeks ago", - "text_preview": "Located on Amsterdam's IJ harbour, you can take a free boat...", - "text_full": null, - "language_detected": "en" + "relative_time": "Bewerkt: 2 maanden geleden", + "text_full": "Dit is een hele fijne bioscoop met fijn publiek. Dus geen popcorn vretend tuig of rondrennende jochies. Bovendien is het geluid fantastisch en zitten de stoelen heerlijk. Het restaurant vind ik zelf iets minder omdat ik meestal het gevoel krijg dat ik een nummertje ben, maar het prachtige uitzicht maakt veel goed.", + "is_truncated": false, + "reviewer_name": "Dennis from Amsterdam", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXLlwumDODR33PnI5LUY92M6bj6bvNAA5x_pRXSDWZcOVXOk0U=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v0Vz1cIewqAWEYapVS1hD2nlFd9fMeEfyutMRJOpqimaxehANrdbbxIj9K46K2Z9uMOTENUJx4FRGEJizmDceSSJJAHdkjYJDJfqY2qyrlYzvP2pJKWI9BPRTvTmZK7vnjZi5UnAIApRo=w600-h450-p" + ] }, { - "reviewer_name": "Juste Lapinske", - "rating": 5, - "relative_time": "a month ago", - "text_preview": "The museum is architecturally beautiful...", - "text_full": null, - "language_detected": "en" + "rating": 2, + "relative_time": "3 maanden geleden", + "text_full": "Het museum is erg klein en als de tijdelijke tentoonstelling niet interessant voor je is vond ik het niet de moeite en geld waard. Het terras, (waar je zonder kaartje terecht kan) met uitzicht op het IJ, is geweldig en goed geprijst", + "is_truncated": false, + "reviewer_name": "Jeannette Onbe", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJjTgoMwB5oye2nG9Mgj5OJyQ-1y1_7lCFspNpLSxcsobeNKQ=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38s9A68LNafGKiA6LyZbDL-u0OoQjbYClQfi7zyrQy-Z5nLTccQczJ1ZheahNgQR2qCOCLunMdBOcmwzYjZ2BkyPPj_Ec3xl7UyqwI0wk313oUT2WUXYTDuRnDi6jHKYjnbXXCHiSOuQGUc=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uFj1KeFIaVEqgMWNc2HKCDtbQn5YaDN1wRT_noQQEjbK6uFlkjPptc9pEG-mz5UWHFiaAXqNegeUlJqxdilD8RcktN4w4-vhxOH2ebV0pHJ85q6XtP202pgIS1LJJg4A-wdthx7nyhUU4=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uupfo43kH0D0H27RAW5kewQAmpzWOjqXwUWSMeGUlewOG16CtlRfxDeRBu3XASHRrZ3kTNqrLw-zZFo33zmNPESCn8v1ULvsIsdts008x1nZEmyAp4LZzXuTAsjlF0OUyhnurmAVR1fYBd=w300-h225-p" + ] }, { - "reviewer_name": "Nataliana Syahli", "rating": 5, - "relative_time": "3 months ago", - "text_preview": "The Eye Filmmuseum is a great place to visit!...", - "text_full": null, - "language_detected": "en" + "relative_time": "6 maanden geleden", + "text_full": "Fijne locatie om cultuur te snuiven, uit te waaien, te lunchen of dineren of gewoon tussendoor een bakkie te doen met wat lekkers. Open karakter van de café/ restaurant ruimte, goed toegankelijk en berijdbaar via OV, fiets, lopend of auto. De inrit van de parkeergarage A'dam toren is door bouw werkzaamheden een beetje verstop.", + "is_truncated": false, + "reviewer_name": "Kati Varkevisser", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV6QHM8TLpnazH5cmCcSspWKuYepA-lzmbzDgxa2Zvjcg82hqg=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vc8SxYFVpYmGbNqO1I7rFtV9IyHB6r3d6JumAIXn_z8IJBptSCHH1uDF6sZnNCR4e0CrViRkFUkfEcu-mljvE-lNL9LIq0AVRfGdmfoxMFXZc9L79gr8zmFw0JGTL1beU7Lk_W=w600-h450-p" + ] }, { - "reviewer_name": "Hannah Madden", - "rating": 5, - "relative_time": "4 weeks ago", - "text_preview": "Love visiting The Eye film museum...", - "text_full": null, - "language_detected": "en" + "rating": 2, + "relative_time": "een maand geleden", + "text_full": "Mooi gebouw, wat moeilijk toegankelijk door de vele trappen, zowel buiten als binnen. Prachtig uitzicht op het IJ. Tentoonstelling van Tilda Swinton viel erg tegen, saai en niet erg boeiend of leerzaam en een soort visuele zelfbevlekking. Bij het restaurant vond de serveerster het interessanter om met een andere klant te blijven kletsen, dus na een tijdje wachten ben ik zonder consumptie vertrokken.", + "is_truncated": false, + "reviewer_name": "Peter", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjX2qRqagWmHrB81FM04EG7hD15BU3uHGpK5JGEZAs7Wtjs-lgVZ=w36-h36-p-rp-mo-ba4-br100", + "images": null }, { - "reviewer_name": "Its A Wonderful Life", "rating": 3, - "relative_time": "3 months ago", - "text_preview": "Eye Filmmuseum – Where the Building Steals the Show...", - "text_full": null, - "language_detected": "en" + "relative_time": "een jaar geleden", + "text_full": "Mooi museum vanaf de buitenkant. Binnenin chaos, slecht aangegeven wat nu waar zit. Persoon vind ik de speciale tentoonstelling waardeloos of is kunst die ik niet snap. Een eleptische aanval is hier makkelijk op te lopen. De luister banken en de permanente tentoonstelling zijn prima, echter was deze laatste best klein.", + "is_truncated": false, + "reviewer_name": "Pat on the net", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUW_plzLl8Gx__uhSc9I8qk2B6mss5jQNFeOciF8fz_DJDN8C11=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uRNDHPn6Bt7TQ7_dF4E9kGPbnluUMpMYoMKUnQFuf8cjXUBeCIYbIGkoW_tXRg-LonSO7SXdzCaaTFfi1vCkpDTZPUGiEd8W9te4fxbTSPSbK8chyTbriQv1sDb1-KUA90eovH=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tFnbRxKH2BMctm9a-Vmvd8IiiIu1Nk8X0DPSCz45OHP8vM7Eg6cYoKgnkbXAMelQfPhJ9czckTHuCwu7oRMSS3TxP6WzD9kWOOKcNCk_kkwdcx3DWlyh06FmVOJDgmqQAKHOG8=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sJU8vM6Zv3C6m81hLgtpxCx_H-Rh0dHA8h1RJT_6Y5b9KdbWotVWNtM6VX42vL_SJsyzlYXG6eefwu4ZQXUvhTPbYNE5bfmhAK_FukmpPrZzjTA4gZotEDcnYEKziAmo2KzQeO=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vaRD0i2ChrFpvEp-ECb1BqWjYGbXcs472EyzHKxHvE6ACTit5yyklOFbAtETaf9agKn8fRKG6POOfCQiWEnUJDY2m8aspTAKmlWqsSitz3y2ozRS99SkZJbEm6-JId5oF-9EIk=w300-h225-p" + ] + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Het gebouw is mooi, evenals de ligging. Maar de expositie vind ik tegenvallen. De ontwikkeling van de filmcamera wordt uitgelegd, en is interessant. Maar daarna kun je alleen maar naar filmvoorstellingen. Het zou leuk zijn geweest als er ook film sets van bekende films waren geweest, hoe stunts vroeger en nu worden gedaan. Optische camera illusies enzovoort, helaas niet te zien. Ik was met een uur uitgekeken. Restaurant met uitzicht op het centraal station was leuk om even koffie te drinken.", + "is_truncated": false, + "reviewer_name": "Roeland Veer", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJYQxJCVs3ZQVPx5lEceO1BnUtveeHUsHAXx5Mu1CnQrrsAnA=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38u3crU60HdBY001nwRT2V1OM0eMJ6btLPECIiLKxZUgplzROyy5DznESTq7ILxMIyOQYowab_2nx0Enm8sBlwJulnk22WzU3-7_E2Y_DPQU3e0ST4eOd8CZpMfNkkNUhtjAWVfy=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38soUE1vD1sl2GObiURRzb0ACHbTamFgyN9Jgx7SMvhefRR-igMiipDIK5inU-rezCSWVr4nd2MaQ4FG9MQSDNal-tfPhi7Cy8X4ch9jySHRgVdjVDSbux0hCqLfGwkocM0jfnYmRw=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38u5l4zCALYlbKAVqHasZS0n7NTv09MGd6QOlAsf8OCq_n1HIc1P0OileJ8sqg14S_eStTJXQ-DteNo78BA7qdhPqwiHPcQENVyH05Nzt7T6yIvE9A6LyArpauOApvI-hDsH3bbE=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vEeuNLTGPmNIqg4lAAogbBAi5iJS2zXvGP4kpnVifNJSWl6XPeL1L1C8zOYNbzCxPPeZH9PQ_OIx79WG7G1P6GE4BmuJbvKeB03WPdWhMsuuMkmnf2D_lgw2hT15llGlbpAbB8Bg=w300-h225-p" + ] }, { - "reviewer_name": "Maya 123", "rating": 5, - "relative_time": "3 months ago", - "text_preview": "easy access from the central station...", - "text_full": null, - "language_detected": "en" + "relative_time": "6 maanden geleden", + "text_full": "Super mooie locatie en het Eye filmmuseum, het filmaanbod en de filmzalen zelf zijn fantastisch.\nHier geen last van stinkende popcorn, krakende chips en meurende energy drankjes.\nHouden zo!!!", + "is_truncated": false, + "reviewer_name": "Cees de Knoop", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWS-9pFg2Z-bHVNknBlxZ6U6bDlqkwT5VQlCrF7aYhHRZByRpZ9sA=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uVLtgjt6F-VF_gW_1490yyV-sMOpO__uTdt7kZypnXwCrFtxpGtB6IsFplT_hTuxmNJLagvb4Rr_mUemWty515Jst-piUmKYMIGTHDNZ0RQk28niRgazAkeF_vgaiMFe06NYU=w600-h450-p" + ] + }, + { + "rating": 1, + "relative_time": "een jaar geleden", + "text_full": "Locatie is top, voor de rest erg klein. Tijdelijk expositie was donker en erg vaag….Blij met onze museumjaarkaart, dan loop je er ook zo weer uit.", + "is_truncated": false, + "reviewer_name": "Pieter van Loon", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVnZwQDnSolo5XxSk5glYp61rUG_ZYfRY5DGX0np6Y4ASY6IDaw=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t3cbiNmCZ4QyyB-JlTK5q-NWsv_tpb12wUoa5wCAV2FReguDTozvVPbMjSrsIM5MH-zimstMJ4W5v6iXFsu4U6mPI6UvklMZ7Q_jfVNNj39w9wfWq0-SwhOUJB6VSEvdTdmp5aWA=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38upEY26AQczfwzuL0FIYzj8KwMr7g45P_CCTWAYUP1pYgk8CQ3_JPRUNnmGk_HY62eOHQt9DRKX56bOSCwrs92saP6gMptjArRoDAe73icmrKqatGdTt7EB7X8XPoXKteg-fA=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tCxr5de1pItW5mZ0yrno2j4G_ydGKa6y0UwvbQ6B_w5yZbvPGZ2jDrOkc5sTPR4bBk26Rfv9mDE5bw-QNfYkxCYzWlYc7h20qzoYBYB5TTTkU1iVPOhoq8ZyaGvSQTxHkLvTOr=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uDIEEZwIxcO4Ot2qaC2r9yM-EsZvofoU76NSJ2xwWVusrgaHpUyVCXSpSygxeVFv9r2XyPp9MB_juRXbmBg7BM72rHrhqOpYVV_sM9Cu1qpwTMqFLpRqaUw2RADTAjb_tgq_I=w300-h225-p" + ] + }, + { + "rating": 1, + "relative_time": "3 maanden geleden", + "text_full": "Erg mooi gebouw op een top locatie maar de collectie is erg dun. Een film museum biedt veel kansen, het is gelukt ze allemaal te missen. Het enige hoogtepunt is het terras dat een mooi uitzicht op het IJ biedt.", + "is_truncated": false, + "reviewer_name": "Feddo Vollema", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJXBfClga_SXdllpyAUTd248Ar8JsslCi8e2v4CMeLUNQR2yA=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "2 maanden geleden", + "text_full": "Zeer klein museum. Tijdelijke voorstelling is vermoedelijk leuk als je fan bent van Tilda Swinton maar anders niks aan. Advies, ga iets anders doen als je in Amsterdam bent.", + "is_truncated": false, + "reviewer_name": "Bram van de Kerkhof", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIoS8TsiJYSWNBPP_AnzNYPW-uZwjBqUQXOAfk8oYelILG4tlVN=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "2 jaar geleden", + "text_full": "We waren bezig met wandeling Stedelijk Noord nr 3 van Wandelen buiten de binnenstad van Amsterdam. We wilden gaan lunchen bij Eye filmmuseum, maar we hadden niet gereserveerd. Helaas, geen plek. Er waren nog wel vrije tafels, maar door personeels tekort konden we daar niet plaatsnemen. Dit werd niet zo vriendelijk mede gedeeld door het personeel.", + "is_truncated": false, + "reviewer_name": "Wendy Veerbeek", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXZBo1jKo3r9EPFz98rdqykDetIs0_Ssm4SuTbe4uRv0m_8lnPb=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vQmvwBD8zLBw54HxHRLGiLkBy2Zv_ajTBPjZw7bOoDm-WEgfVH9DuKiD-jR1e-_R7_pn431Sz_c_f_K-HFZBv-tUnhzZJTtAzFVjwwsVx4g87Iwq-JZoXtY6_7uOK9L3w2PZsw1g=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tFFnY0CopgLGHwtq-tfxgt9yUmjNm25w2JW-ciXZqBfDnQK4HA6_l_r_8Zr_XIqNZYDwNx2k-JrKMAVjEBgkbXICMDy--jrfNBEbDhtQvM7O9Vh-c352ukqSPW0fJ9SGhV5GZM=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38td0PKkaya-rxGfX65MGKObXDQ7FD9leyWB1DRFrZZr6p6ZI5TkmUMR0TsOi0PZVsctpv8G78zhmHoMs2ZohgdjvcKEOczFMcVlR1LHQ7x4ANGarzOt-QHXvk1Wna1q2gHdxtI_=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v6ch7PE-mROxWk6Ga9mLpiWQ1D3JWz5Gd-Gd6ohV4AP-4gAtMNRc2FLj59fA6dziqcFXswycMMjQvZr0EdjtYiHvLOe-t1ede4oTZH8fDtgQh7qmN0rf_2yV6EVW83N65latkZ=w300-h225-p" + ] + }, + { + "rating": 3, + "relative_time": "3 maanden geleden", + "text_full": "* Prachtige film Aïcha gezien. Prettige bijkomstigheid is, dat je met de Vriendenloterij VIP kaart gratis naar de film kan\n* Prachtig gelegen locatie\n* Minpuntje was de espresso macchiato. De koffiezetter had waarschijnlijk dieptevrees😅", + "is_truncated": false, + "reviewer_name": "Maureen MuMu", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjViHUi7tR6wTGg93RqxABIhnPscWsTyXOuKO9gb3U8c-mL5Fmsmug=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vUoc_FvBJZps36OIWfCzGRRwoVcMQ5FCaFo6vNMQLBN2Sku-HkbTGSF7ta3hZA-7d4v1mxKe1gr6F3gg9Sss5ghtm1UJGQ4VQBC0qfMj__k3PY0Z-xYErsh0fVwV1rpjYKW5ueMOmvBiOP=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uCzQsvbqmUyhE9cyWVwZzBESX2R_qywpaulpbRH0-q6ZGJW3mzjG9NqEJLaRKyDvMD4n-gGtY8UAQuYlIn3-qUN7rItjCiIHGiSOEPtBCP2Yw3DMfUXJ5qbI7paPVl2N9Dvj7jurn7xYk_=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "8 maanden geleden", + "text_full": "Fantastisch uitzicht. Het museum is makkelijk bereikbaar vanuit centraal station kun je gratis veerpont nemen. Deze is gratis. Een erg goed restaurant. Verschillende exposities en voorstellingen. Kijk vantevoren. Maar ook heerlijk om gewoon naar het restaurant te gaan en van het uitzicht te genieten.", + "is_truncated": false, + "reviewer_name": "N Kaplan", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV2IFutqif7mmpMOzO8cspO3x_FxiIb7hx8dVIXzKfRO2kUTFnY=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tjED5vXpzbjqB9GKxZfampSTAocbwalbGZaOsmzKG0y6Vhl8dmURXyE18m_Kx82q2twPZ8vJxrLLsfQ1ZJJe-frBo3r4ilnWOXazvjZr6rVH0w3C19s7Q1_dgSTkpSPo8Dt2QeOA=w600-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "4 maanden geleden", + "text_full": "Wat een fijne plek voor filmliefhebbers! Het gebouw is modern en stijlvol, en de tentoonstellingen zijn verrassend en inspirerend. Of je nu komt voor een bijzondere film of om meer te leren over filmgeschiedenis, het voelt altijd relaxed en leuk. Een echte aanrader in Amsterdam.", + "is_truncated": false, + "reviewer_name": "Sebas", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVqiVftExKnMcxhLooL9UIZHTXETiF9ZH1_yqMMrqEfzqFGPeZs4Q=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tlvQC5OXPF3V86gh4h4c2IwM1K2MsBKt7aj8U7QNV2I8S0F8xiAjbSp9LvPMh7N4q7cJAZZCWeQAy6uFswuCOhpls3ohum4WAY6aC6x6zXCTzx9KFb6ktFcU1_0xRGjK8_gOKYimMlA0kU=w600-h450-p" + ] + }, + { + "rating": 2, + "relative_time": "4 maanden geleden", + "text_full": "Misschien waren mijn verwachtingen te hoog.\nEn had ik ander beeld van filmmuseum.\nMooi gebouw vriendelijke personeel, maar had meer verwacht van een museum vond het te experimenteel.", + "is_truncated": false, + "reviewer_name": "René Drost", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjX39J1-j_bEMlpgY94YGqBzZq39d36tfFu22e99rtyQ7BHoksUz=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 maanden geleden", + "text_full": "Leuk museum! Gratis als er geen tentoonstelling is. Veel historie van film, het maken van film en de invloeden van film. De quizen zijn aan te raden en er zijn zelfs hele films te bekijken in prive bankjes.", + "is_truncated": false, + "reviewer_name": "Mike van Lieshout", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVzBrKwT5OU1aVLBzw0k-tjHgFJaU3XLNV3lYI4cAeOv1y_Wp8LNA=w36-h36-p-rp-mo-ba5-br100", + "images": null }, { - "reviewer_name": "Moses Harding", "rating": 4, - "relative_time": "3 months ago", - "text_preview": "It was pretty cool, especially the one room...", - "text_full": null, - "language_detected": "en" + "relative_time": "3 jaar geleden", + "text_full": "Tijdens een rondje fietsen hier gestopt om te lunchen. We kregen keurig een tafel voor 4 personen waarbij we zicht hadden over het IJ. Er voer van alles voorbij, grote aken, plezierbootjes, de pont, politieboot etc. Mooi afwisselend uitzicht richting het Centraal Station. De lunch was perfect en de bediening heel vriendelijk. Géén 5 sterren omdat we geen film hebben bekeken.", + "is_truncated": false, + "reviewer_name": "Toon Niesten", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXk5WN32keWko6WD2_UGYjg6hX8yOvxfggLYRzm4AgSxOOG0url=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tGD8lSupnHw-VScn3fjXEXuI9LR0BYYlacCFQMrwrW8RNccnl6NK2qXOptP7oS92wkEmPbhzpv5qOceWjsajfX3brqB3xxMnYh8SmoQMjECRZ2DDfWMNMfffH9psgff4NdMHuo=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v_rdP3RxCEHN_uAXbJuFvlFtmxe9Ue9sbD0fsNKnLUyh7QOCiSufqhxQJRRFG4OHq45ML3Js24diic1AWI5uLrFMCaB1BWHF6sYL91JN2aDWWdXo-hgtbsKrPsokMjVEXCjRhWZA=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vQ3mID79WIWmb1abpW2wh6uDn8SQ2WEpE7rORB33Sd3geg-uImF4Z3sW2fNrxIYi0PTg_YRlHJeELP5FhTv1rJpOQaFgJt1p4ZBpXN9e123vZE8nbTSaRSVIkTmTU6lE6mz6hN=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "De ligging en het uitzicht zijn uniek. De tentoonstelling van een Oezbeekse kunstenares was prachtig, maar loopt ten einde. Een minpunt; de bestelling in het restaurant liet lang op zich wachten, terwijl er veel personeel rondliep.", + "is_truncated": false, + "reviewer_name": "Peter Nijman", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjULCcNBkZXpy_vb4yG-aFXfci9YesHtQ40YuvsuioPk5cxRyus=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vYTyuTI0-16BMn2sJB6iL9kLAAQbs7-_uDSI7lscAKTF_xL-iT4VtBQq1bodY4iAi6uiIl8q89xSVH3oD0VjU8JMa-35foOwRzDVQgMI8178hFJjwjBkttMJVcl0vY7gwVWfh6=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38um1vAy147-WQY5EjoS4gVwyCjyui2WGiGDb4p-TeC2iwvllFLJ3CGG7tT7wD6RY7lcuxi8WAqB-O6fWUlQ-aaMkipYStE8_hyfqZ0Z7j4X1QXoFnKMCZO8w8pTnZNf5fspOj1X=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sl8sXUcG-urT9mog-e85zroHUlfzfQltG24aXUfKFfxfc_kxzn4vnBTxuExuyyn0KuxeRMheIoVWb5mLR-Q_sU4IBXgJmFrS6J_Yb0IDgic3cRlW43DUSuxIv5A3J_SMcHDdui=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tfKx9IeZ71YuqSEho6GzcyAJKSpLNv0YuYP3rGssMPfv2olj8Uvzqd886iLntAvSPHzumyc7JSoILSmok4C5kp7x_WAnF4EFfSd5vhAykS0RZvE3Zgr03lmEgI732YiuSNdy_zjA=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "2 maanden geleden", + "text_full": "+ Mooi gelegen locatie met prachtig uitzicht over het IJ\n+ Prima koffie en gebak\n+ Wat het helemaal aantrekkelijk maakt is dat je met een Vriendenloterijpas gratis naar de film kunt.", + "is_truncated": false, + "reviewer_name": "Maureen Mumu", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXY6vdDjUxrQhgv20zWlDdAV7i6Ipmtlt02J3HrYEjEB1zbjcE=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Interessant museum, zeker vanwege de wisselende tentoonstellingen. Goede horeca. En fijn dat er verschillende bioscoopzalen zijn, zodat je direct een filmpje kunt pakken.", + "is_truncated": false, + "reviewer_name": "Onno de Vries", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWMKa71_tDe2v90Mi2QsU4mHitprDZQByp-YH2MeHllio6HX7Q=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sr5jCHklbzeJ8MhmTKft1pHU5dziOed76bxAQbi7GatzG6zZlq6hNBgBJVtlaXB4n6Ao74UK_tXomTWEzw4Icz5k4xm7eyvy3qD93n77B4vqz4TSk58d5LOVFpmOR0h9J-Fjxf=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uul2rwa0a1Aqhkemni_EX3559k54sIfI9OqfhgEFjxyoZjW0I4X1JYjbq2fYpjR5FA0qq0LbscCXRbhyBME11ohcxFsegYDsRPaBlTzd1Ea0o-l2d3sW8T18I5N8jfS_KAlDZYEA=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sk-9dKswBuSfFxy6eXd-eCOc24XpsmAOzRbKOqbeyCxgfH6M9h3Mq_zyQ3UDTX2bxc1CM0rSLWL-THEIwfZsmGkwG3yBE98tzGOW2ewmGxUO0Ys7Xl_FDZRgpAf9eez9itwrbO=w300-h225-p" + ] + }, + { + "rating": 2, + "relative_time": "een jaar geleden", + "text_full": "Een prachtig gebouw. Mooi uitzicht over het IJ voor de iedereen toegankelijke horeca. Alleen geen gebouw waar de geschiedenis van de film is te zien. Wil je een film zien die er draait die je aanspreekt dan is het er prima toeven denk ik, maar wil je gaan om kennis te nemen over de geschiedenis van de film dan raad ik het af. De naam museum doet het absoluut geen eer aan.", + "is_truncated": false, + "reviewer_name": "Nico Bink", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLi-VqKeLwnVmf1ZoxZGtLtRBUFL-fX94vppWAgZxmwsgnZFXpO=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t490jZx0kKzmUryJZdapPo1Z-Oqaw4VW1oqDG5m0PGqcUMhiJpGepNki8SODKTYoXT7os61AvtNu_E8_Sn3QW4Hobaxvgbk7-Kr72Q0vaLomVlfd9DoAQTJJxZOgxZ8BIm5H5a=w600-h450-p" + ] }, { - "reviewer_name": "Seokjin Ham", "rating": 5, - "relative_time": "2 months ago", - "text_preview": "The museum, opened in 2012 on the banks of the IJ...", - "text_full": null, - "language_detected": "en" + "relative_time": "2 maanden geleden", + "text_full": "Jammer dat er geen update was over het wel/niet kunnen bezoeken. Ik heb voor niks naar eye gekomen vanuit Den Haag", + "is_truncated": false, + "reviewer_name": "İbrahim Dokumacı", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUHzNo6hxU4Kjy73KK4n7jdUVjy4kypGKfk93in9QmEN2_Uzwjp=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "5 maanden geleden", + "text_full": "Zonde van je geld. Het is eigenlijk geen museum. Wat oude prullaria en een paar posters.", + "is_truncated": false, + "reviewer_name": "Rob P.", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVOyePbGB6p18zbogQN8CaAki0_oq1GqYTQq_dhRWM1xQNV9PZ4yA=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tks1VIoU0D6bFy4HxgQHHB7f03vqmmkZZPMRzKj-ACEAzHE-xB4EA5TKpCh1iJkD8EJZurn3F1RPs6PrWCY3kQfvzcjTEsOoApWlw-1bjQnHwiCJm61er8woJXSUMItk02wR5kuy_U_DE=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uWz39JaJPwuXfiViojiSRJ7h2K7y27Bx1uj9R1iZrtufN_jjM5oQ-UrqwXNkN0xuEo2A4Kj_-rSLcRCZVveyjjVWMfiNeI5wzNsyVklLeRpugG0CSXdBJI3ijNSK2R3tvs08Bh5ZcQ2pU=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "een week geleden", + "text_full": "Stad is Groot, views are very beautiful, ams is lekker", + "is_truncated": false, + "reviewer_name": "Stephan Van der Wijk", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKRxghl_ajo5S4Su3qGC9_VFqSiYJhSOkIPsBindod2_7q64w=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "4 maanden geleden", + "text_full": "Prachtige locatie, mooi gebouw.\n\nHet diner viel wel wat tegen ('dubbele koe\") terwijl het niet goedkoop is. Desalniettemin aanbevolen om deze stek een keer te bezoeken", + "is_truncated": false, + "reviewer_name": "Frank Braakman", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWOiCbsORIuJzKLzryTWRAhhQEq6W-uYjnfVOKkfWc3Kim2b9B3=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 7 jaar geleden", + "text_full": "Erg verrast door deze museum. Er is een groot archief van vele verschillende nederlandse en buitenlandse films en series die je kunt bekijken. Vooral de 'pods' waarin je kan kijken en quizjes kan doen zijn leuk.\n\nAls puntje van kritiek zou ik het volgende zeggen:\n- meer over de historie van film. Op dit moment staan er wel wat oude apparaten en is er een stukje historie beschreven, maar ik zou ook echt wel wat meer behind the scene dingen willen zien van oude films tot nu bijvoorbeeld.\n- Meer pods waarin je kan kijken. Nu zaten ze de hele tijd vol en moesten we wachten. Het was niet eens zo druk, maar je zit al gauw zo'n 10 minuten een quiz te doen waardoor de pods lang bezet zijn.\n\nOveral maakt dit, samen met de LookOut en This Is Holland, amsterdam noord erg aantrekkelijk als attractie van amsterdam.", + "is_truncated": false, + "reviewer_name": "Freek Teunen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXfHJHonEvNR8JJ8JcOclpTfd2WTTKqNma9KqViOJsRb22Cvzx7kg=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sRva-0ps7nh33A0io-9ukVEFzPshXrGE5gRtMmKrtI4NehnwSMSAJlNJGi_KOGd9Ul-8fgVCaaHpfcylj39_WA7bwQeaKsCixxTx24tT6nbKxjAHOxvPBQvyUXX9knSegaVy2YTw=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tSzj7XFGzukGDIOUpaHsrGuL3F6gAxkRYCCnuLsJpcAdnh_RkqLc_GN-pJY0PrruDjoAkRzCce-3zLezqbYvz6Cg2AZsVklpOkf4XxoQ-QmC0ZEFcRb89EML0GELbnw3DQK4e_=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vt3XXeb-NgnvBBotiD-CFlcqqpS4mQYqPlfbvQ4KHCr0Undbx4smNH-zDWUrnJgdtU-iFKa98KH3c1tBiLJP-HSAK7C0EuHJy9tpV6NI3qSVUF50t0kB-wwGjZbl0-0qKAUAkXQg=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uVnB83rXARkLmKgxKF76NfgKEPS_6kd2FdRnsEWYLWHYGHITyAqJA5TsBFZA7JQoL-DOnm93weerbhpCiuG560McLLYCeeHgTBcy2FAVm0T3_O5BMuXnVL2JuouTy1CEgV_Dg=w300-h225-p" + ] + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Een symposium bezocht in Eye en daardoor maar iets van de collectie gezien. Wat ik ervan heb gezien was aantrekkelijk/leerzaam.\nHet is een bijzonder gebouw met een mooi uitzicht. Alles was goed verzorgd. Ik heb geen minpunt ervaren.", + "is_truncated": false, + "reviewer_name": "Michel Hupkens", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXa7t4Qe4sYKLHx4d0YcQnu_ilv1gSN24QsKfuWba7-ET7B6iqIsA=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tYKVQSPGii-npcuEPFkO1qbEA15tqsDvpwqmOBV9MUHMG5A9qqbBRziS1JdKsnQ4DyHwttVNuS_GcSUZZfqLGn5XGAx5aXL0Dr2KX4uh1brUUZmYENakk5-WYTm4w3v1pOcP2v=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t2vTge5pclZ_DnvjVG1zU9jb3uiOr96FtzGyJLewsq3AFOuZKKIGtzRCTQtl3Tf4IkLfPEqkufBQGrXRAlcZnaIqq-ycLmLNzYfU4Vc6xCM6Kelpri1UE0Si1rY5x19iKF2u2w=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sVdSM4bt075w12-woYXffBALCi0w0AawM-mZsnddxY7Q2uI2ZrHXTQ1JNULMUwBi8jSjyjmeG4l8ivDmUBXvMq3rJgVVEKr9aZsY-zbgCiXfMITL4mY9a2FRAeCCG8sZB-Zes=w300-h225-p" + ] + }, + { + "rating": 5, + "relative_time": "een week geleden", + "text_full": "Moderne filmzaal, met sfeer die laagdrempelig historie van en film aanbied.", + "is_truncated": false, + "reviewer_name": "Luciën Sno", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUrNbw6NHmikMsdS_TG2B2XDdYHWaeD1t6dcrBZQtXXqDcR73Ao=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 7 jaar geleden", + "text_full": "Fraai gebouw op prachtige locatie.\nIntrigerende tentoonstellingen, zowel\nbinnen als buiten.\nAandacht voor bijzondere films.\nRuim lunchterras met vlotte, vriendelijke bediening.\nMuseumjaarkaart is geldig, toeslag bij tentoonstelling.\nToegangsprijs voor de permanente tentoonstelling (op waterniveau) is - verhoudingsgewijs - aan de hoge kant.", + "is_truncated": false, + "reviewer_name": "Colette Jacobs", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJFN1Ba6w9Y8F5saMTI3_2HQgnQ98MB3WimYEIc0oqFQK_maA=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v384N7v5zNFZLo6tWEkm-LFAv--fj8mtc-Z3bxnj4Zbnkxfcs2IvR027is33UFHnzBchfl2qQN8yQ4nJGIjw9m4v4T5hdlxEyoyr6i4or6CXherOgh2D3wyoybkqLEJGjkH_ucEg=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t7eVtN2OF6czra2IPj2AnDEg0JyaPi1gwxUeMNaW1Mf84VEP1V-_VZj65-nQQ1qWq5yNVH_ANm35A9BKBHQml5Z0yYvKRyHUy1sZYpzZemBWiq7G454hFpTWqvKqEndji1VQa9=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38ve8EJlFCufG5AyXx9cbH9jMeu-ne09gKW9lnB2ONvxfab0PqEMFc2vgqWVybGsq6S6CfDpKRPNKMr8zYri6_H0CzBMnL3b58z38RfExS7xtF9infiZryxypE-xbyHMERGDJAML=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Een heerlijke top locatie om iets te drinken of eten. Vorige jaar had ik een Franse vriendin meegenomen om te lunchen. Dat was heerlijk, ik geniet hier enorm van de sfeer en de uitzicht over het water. Dit keer had een kennis mij gevraagd om een kopje koffie te gaan drinken. Dat hebben we ook gedaan, was wel gezellig maar ik had totaal geen affiniteit met deze persoon. Ze vond alles niet goed en was over alles negatief behalve over zich zelf. Het arme schaap wilde niet eens een tweede drankje nemen want dat paste niet in haar systeem. Ik deed dat wel en nam een heerlijke latte macchiato in een lange glas en dat was gweldig! Het was niet alleen heerlijk maar het smaakte ook fantastisch en ik werd door een zeer aardige en vriendelijke \" good looking man\" op mijn wenken bediend. En dat maakt je dag compleet.", + "is_truncated": false, + "reviewer_name": "Najima Gilber", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIyfkyt0eB2HCdq4wIfH1HiQRC0YzQD79Ir6b0nwnFRntBCDdUi=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vQ_JXiTu5Wr3mPbmnvQsz0c23yZVvDTOJH_6yGnSoclMyfQUZoAWo0W0xfJYZOhdZGO0OfJxEIA2UQeFYG2-B6Tcn_jVouCb0_upJg_BXXCMUUbLNMpHHo2_jYys4gqRwae4fX=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vN4Q4DexUV2c9CJ3bZkrmpqIT_2kQRTbpGvx_kvmmveZRvU9y_T9qkPSYeCm7esbhGKbl2OHTmTueHDZJq6rO8QyyHCayYA9b3cDelJGbv8JwyPAWvw6mJFJpUh-iKkv9qtgrX=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38txtxuZaixjnXl71DRDwBog5CeemRE7cagGchF2Cl6Euszvb-T2f-EUqY1SBDKH2QsHejWyzs5fo2FuefHuzloF4CiX7cV65K-0smp11vJe7og5MPQrGV_9o2J3ZA_a_LINfNe_jA=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "8 jaar geleden", + "text_full": "Prachtig gebouw. Heel groot binnen. Erg leuke locatie om te eten of te drinken. Mooi uitzicht op het IJ en het Centraal Station en de langs-varende boten.\nNette bediening.\nBinnen is een leuke winkel met originele film spulletjes.", + "is_truncated": false, + "reviewer_name": "Martin van Dijk", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVTH57YhHElcMUvtoFKZCm0rFRyGsNm8U5otnHS_ZExZNOiiYCZ=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t0hMkQ2N-CSVsZZ0XLlwndpMziKUXoRveK_0DZrZ0OxDgmQfJa7V95XPJtpHIktFEO22BZUSTjMT57AvcOf9fEnwyX-DazKCRoOCR_F04nEV5RhscnzoPMmjFj8qFOC39r-rrm=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tO-X27wLUqAJ24yeL3lA4N0pU0DDDm6wez_0x7xJDYg9e4JSvp5A-htnuCO877JhV_4CO_XhtvYsJPEIJhTQhuxOFuTX7jULdXtwEKgF1e82lTw-FmlcE7zgj2aw0wUeLlpF2x=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uTr1wbm7ZBnhM0IP9jj8VHXGhcfFf_LTpcMkx2jz-OnBwc0atYQ9FpfJW-QkcUSHHN4fzcCkTXA9ez3tyf1XEc9HK62yxfIj9tLa752nweVsjCXuVcT4OO8MDoCfjlR-7T5QliSw=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tBZWYy12D9ZYhA05KlBSQOioBYR9VCqb2bTI7fWy1aujO6QyO9Mg4B9HO0_YxcQ869PbCbBCz-B9YiGPDZ8ahOMJCCGmbrBKkBJVvblJV3_L3e3Fffr-W2lY7erw85ylHKVx6Y=w300-h225-p" + ] + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Zeker een aanrader als de tijdelijke expositie je ding is.\n\nDe vaste expositie is goed maar heel klein.", + "is_truncated": false, + "reviewer_name": "Philip Van Mechelen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUmHP41SIbcuDp35pepwIy8Nuup2LsjLCoxV5sINjV79KqRhsc8=w36-h36-p-rp-mo-ba7-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vFsLcR5JpQ4pveNfcuHTzb1aWVqm4YAS4OA6Dokl8bWZTZjshc0ux8EILHOQRywBEdfmSffVFlMiHHC_D4znnixI14X94OxpOrttMQHIR3iuGb9XqQSSE29yH6mh5EPm0Ayf6T=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t8cydwZVmNMOGJif0sEL2_C5luUDrMdfFtlQdfNz65gRKXYtIlQ1nes2m5R-i2iXkY-1Kwc_5L9Tx4WF5rPLgMoW8nyW-MKSjE0yuteXZr-3CN6m58e0zjoaTKhwa_lugRg60BqA=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38u9HOvd9M3mxHGM1eAwEVtlTfh_mW3VRDFv5zUxfytkQ-1HbQkigvQpAewLVMa1f0usvMoGAtBzxAQtoNdh74r7UOv-u0RS__BLzaq6UMjcrmDRA6y2nXoVyW7sQwCK_JdQJVfB=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "een week geleden", + "text_full": "Prachtig museum voor de filmkunst op iconische plek aan het IJ", + "is_truncated": false, + "reviewer_name": "Albert Neeleman", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVxhOgB1RQ17tBMQPeR2-fwFwe3MexBfctItQjbCeZb5aYccTBw=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Mooi gebouw, ook van binnen. Bijzonder dat je er ook gewoon naar de film kunt. Tentoonstelling moet je wel even de tijd voor nemen.\nMet pontje er naar toe is ook leuk.", + "is_truncated": false, + "reviewer_name": "Jodi", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKF6NfE2t68p1Odm3sAmHYba-PPBsQJu7SHUc6kR4C3WsArGg=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uoN3rwFCIr62foOz2aUBZTwLLltIf3GOoZlbutCl2ZdciWMSE8ImfBkSIxPEJFRCzEkuQI0AiNmEhwenRYA_R97Si1D1TR5MDTBgNfwmZn1xqImCiN8Owq61CgznlrL2vqOmc4=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vaoniXqKJ5dfIM14g8cuwbdTGu2Trpnbcjk8D7G85miIbo3c7DjP885578qeBcJSEWIu50kB-dKXVnjR4CvEWnp5YSKfQx-MuGijoRL-SIfTdR2iPnpXD9ihIwWBvOWvI08YAq=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "8 jaar geleden", + "text_full": "Mooi en sfeervol restaurant met een fantastisch uitzicht. Het eten is erg goed en betaalbaar. Bediening was wat minder alert helaas. Zeker een aanrader! Gratis parkeren op 10 min loopafstand.", + "is_truncated": false, + "reviewer_name": "Armand Wens", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXjPIS6n45nZqOUz_0dT4-HNwpfQqz54_2HEWcXLKUCeCMrz1-Ihw=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uvU8Pwn4ya4RdFxFbsD5nhpyud8sCK0x2ZILTwBD8M5mKvSejC_Y93ckD_2X4okxPgJkwAjhmHU8UJLiBH0lUT2zc3d3P0k97mch_puFU_ZyCZqLJy0mBekSS0inlvAUgq7Iy_PA=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uBJiNknquxvcZX_Bf_XdtEa1aPhVagGO3NwZHnOQzEtvhguxQTLauFtaKA-TBT89W7vCsrMcWY_BVu4zN174oJthfyrB-uO9iJ-onhCED21WQ2GIBdrHAUfnCiH3NnlKRQrqip=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sj9u6J-dUbh-PnDPIMSPEKZnam1B2mQmHHxRzMA16aLGsHzBw5U8lYNfI3z8czMDUAb-00A_b0MzYWkkbgaPZJLijYPRA6R_YfbNosapTaJ_o8q9MAYIuj2QwkK8e-xfjvTXw=w300-h225-p" + ] + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Dat is echt een prachtig gebouw aan het IJ tegenover het Centraal Station. De film Anora gezien en die is prachtig.", + "is_truncated": false, + "reviewer_name": "Ronald Hendriks", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVhKeJ1hqjqbwNNcGzvcQfyfJtkVgY27Cx2BP5sLxz_wRLOxC4S=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vwDpjw-EgYVV76LxtywoBzFtCAguwyooEEyYpmJPfY8inglOqLk4m0-MvPmWtP5igwffKoSxbCxAONu2vHmP4blPlQ9tTTpQzQA9VbRrTuyUDtPE56tpxHsiaU4mwYR28XVqdtGQ=w600-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "9 maanden geleden", + "text_full": "Elke keer als er een nieuwe tentoonstelling is, breng ik een bezoek aan dit mooie gebouw. Naar de film ga ik hier nooit, dus daar kan ik niet over oordelen, maar de tentoonstellingszaal, het restaurant en de museumshop vind ik allemaal heel fijn. Leuk aan de museumshop vind ik dat er continu andere ansichtkaarten te vinden zijn. De huidige tentoonstelling over het werk van de Turkse filmmaker en fotograaf Nuri Bilge Ceylan vond ik opnieuw een mooie tentoonstelling. Prachtige foto's aan de muur en heel interessant om verschillende films (fragmenten ervan) op grote schermen te kunnen bekijken. Topmuseum!", + "is_truncated": false, + "reviewer_name": "Leo Kroonen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVdDstBGZXgTPvTFenzbEJNKepDPaJmp8-viVeRL_5fnEC0oVmi=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "10 maanden geleden", + "text_full": "Super leuk museum! Goed te doen om eventjes naartoe te gaan. Leuke dingen om te zien en het is ook een bioscoop", + "is_truncated": false, + "reviewer_name": "Laura Koot", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUrDM-I3O_E5mealsQW_Md38-ElwjCT4XBplv86l4JHYxWbYVMEkw=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tl0q3PU35K35Ezq-HH2bJ9mQ2dX-gpZ52kdKHJ9EuNN11CfbAaDZwJSlJMTqTgJw0Xk9PzX_bbOIjoDOD2ffTKrvMj0yg3CpckIItBvLV0_YoFYUuY6np5ZsxmzkLrSCM33Nuz=w600-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Naar documentaire over Anselm Kiefer van Wim Wenders, fijn dat die nog werd vertoond , schitterende film.\nVooraf heerlijke thee en gebak in het restaurant. Ik had gemberthee besteld maar kreeg muntthee, serveerster bracht later toch nog gemberthee, chapeau!", + "is_truncated": false, + "reviewer_name": "peter meeuwisse", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW8MwjMZLFFc-_rcs9_uUe5LkUy5psNGDWQ02n-RWYSnIJfhYA=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vcXhGaSLgehpCVRQ3kUJdotGLJZIN_ZgXTPFy5MhqBvtyk3P5nC1yHIPH7-SgAjpG3_R8eivHm8BI8lFwek9IehM3g4VQrXChJB0T1XO-2uRjdl8cvjfgIW6CnhjlZxl8fBbKRQA=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38ttA88mFrATq0y2BzZQbJkXaCwQl5j7V7YgEvND8D1jQ-r7o1-3IHHyk8GJ8fkypOuymHTw5sD5OxVPOwWtNJzNehVcuydd8e9MZ0BSIhP_zWl-136iGoNPMUwrlSOMIHBcWwVu=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v2jo6-XrM5Dg3nVj8cA8ddINjq3K89YDvLXD9hgi0fNmyjy7QxMrNOscVOANhku--cDoSw1LAw1mOMj-pRn6141b-ZBcHNeY1KuZL372EsNdb1-nZTGS6QdEvrcwu6eQnb2NxEIQ=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "6 maanden geleden", + "text_full": "Prachtig, maar geen popcorn. Ook de bediening was supersloom met 1 pinapparaat. Ik ging tijdens de movie snel wat drinken halen. Nou, 10 minuten gemist. Zonde! En geen popcorn! Ik snap niks van", + "is_truncated": false, + "reviewer_name": "K A", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUl1jB6ftgeUATbkKgQWMh8wLm8HI1cOYW3oA625MnoGVoOs-Hu=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "7 jaar geleden", + "text_full": "Mooi gebouw. Vriendelijk personeel. Wel vind ik de expeditie’s wat klein.\nHet restoratie gedeelte is goed. Ook als je allergieën hebt wordt hier erg goed rekening mee gehouden.", + "is_truncated": false, + "reviewer_name": "Edwin Fuchten", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUj0jyr4YdziucCkjWOQiiz2OVCTKvelKe6bOEO9_4Iwn5e4mGcIw=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vaQRE9hMmgTj-1fRJBJSDc2Rfr-kT24Tf23IektRU-16bfm8BMf3MSN1xI9LBfheJVW_2SmaN8Px2xE7bWERvtexYNB-hgUwVxoW8-7d9MlKVxxa3S3qvCwO_babqqEI3ZTdQWOQ=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uKPGhJz3HtUKonsoUQPxzSxVe-iJ7tqiFT9pNXVv23XbeTvURpEzzN8Pw0q-x16CwV2bqBh__otCv_PiFEq1Ri5F6uZ59eyL6WTjP5mphgrFE5QUinyyUj2Uqr13giZK3w1Uot=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tImNVBsziESQT2Shjn_NTh60NzghVvHXa-pEsmu3S6jki36by86Oh9v8Q28mmw6JMD5Lzb198nMgbmLRG2wtcnqRqOheJQ6W5rM85Nf1QrJXw5s3lKFjV3QJ0ilHSQgthIoYvnZg=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38scBcGt7TDStMosFpNtMCTAjIuIIw-ifwThyp43N9vHlVJv_1iQZ43cMXL76hnUOEC1TqslKBDZY02Ep_3-JxUEHVhN7cOvnMbnmxcwwEI3cENi-mqt97WFT8gZ9BM0nD0Hk78F8Q=w300-h225-p" + ] + }, + { + "rating": 5, + "relative_time": "8 maanden geleden", + "text_full": "Dit is natuurlijk dé ontmoetingsplaats voor liefhebbers van film.\nPrachtig gebouw en prachtige collectie en regelmatig fraaie (thematische) tentoonstellingen.\nEr is een soort Eetcafé op de begane grond, maar dat beperkt zich tot de lunchkaart. Vanwege de locatie is het niet goedkoop.", + "is_truncated": false, + "reviewer_name": "Arie den Draak", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXpNgaSnTeCzgvXKLyn54LjCIUwGLgXW3bvTJQTikR6UI44SfD8=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Mooie tentoonstelling van Saodat Ismailova. Mooie uitzichten en prachtig interieur.\nBediening op terras is jong.\nFilm Tchaichovski's wife gezien. Zaal stoelen iets te hard voor mijn derrière.", + "is_truncated": false, + "reviewer_name": "Patrick Somai", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWpz-KsAUrbej9cyKzGXuxC3Dg8OFiSuudrX2hsTYlcia4w6zg80g=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38upydhoX2lTT3HPtinBunJsTGJQGyS4KHsS_N58c1wiPM0NAGUwRNsyJTWji9DHZtXTJf2fmllc61Tp-hUBsJBQqRzkJBl8Sny1YeA9RLsvpiBbSVmfqBYDysPVLjsYT2x9JVg=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tY_n9YwvV4hWFBe7JRRVhpMdXmfkSvWRCMwyjZJCZAOzMiuosVu0CTW6iNB7brBAgtbfKzqyo0be3YTZT2Yke5xE1uTRYU76DWPbMdYXpJC-svvo7A1viTmVp-hkQK6381Bie0sA=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "7 jaar geleden", + "text_full": "Een van mijn favoriete plekken in de stad: mooie locatie, heerlijk terras en een prachtig gebouw met goede bioscoopzalen. Persoonlijk een groot fan van de (gerestaureerde) klassieke films die hier gelukkig regelmatig vertoond worden!", + "is_truncated": false, + "reviewer_name": "Sander", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUHkZ6_wb6mXu-0lK3SFPl9WVFBWW9T_7hu7O8UzcbQWHhgkIs5IQ=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v2MVNdGFcfHdFaybXwQYcB1sk9AbrR_a2CtQiZDrxIEfBMvv2yEc38ws6AVQDKSHFge1yYrYIo92brif57JX0qF-WqWL-5kI-bmCdVYKXOt1h8ga2omVMSZ9jlLyt1_X1h5h-uKQ=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vq7q6yJdgCOJRqqWKjROImNc-jH1QCMWQWRocU_MY3A0uAlS467Ag-k8WovaHFzFQOFKRJXYrsUblsMwnymDmHca3VPQrZPM3zPCot81OYyoT75jQMOsRwqPKgbZ0Cs63Cd3YPTg=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "3 jaar geleden", + "text_full": "Museum is geweldig met heel goed aanbod. Echter het restaurant valt tegen. De plek midden in het museum is mooi, maar de service was erg slecht. Er liep veel (jong) personeel, maar waren onervaren en keken niet goed als je geholpen wilt worden. De koffie en cheese cake waren wel erg lekker.", + "is_truncated": false, + "reviewer_name": "Solange Frankort", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXb_KznbvJ0HjEVeHfe_0N06XoRP22rVK9lkFucrg7hTPi2YbU_=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38u_CKKKYblhNv9Sl2jWpoXK4_d7qQbEBAGFjrlq0wBMNbSuKNi8bWfyRFpCC9fhzf28WZf8oAj_5CTDEgEP6_b29RNHlZOfGsbh2oO8VZUpO_6pSTMXq3m1BsmQxF2wj8jCFUcl=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vcFO3IXeWhcCm-lZRlZJS0O1cVryRYmjIp8Nlk-wD7F4NlL7aMxGvXau_47uAuSTgDLI56GzFr63YOioywAJimle4mR5NHUKtdgXtODBtdgga1HaJxZtHDO7nmjGBC2Hq8ZSCI=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "9 maanden geleden", + "text_full": "Vijf sterren voor de locatie en de kwaliteit van het aanbod, drie voor de klungelige organisatie. De zaal waar wij naar de film gingen werd verbouwd zodat we niet comfortabel zaten en uit een moeilijkere hoek moesten kijken. Als troost kregen we wel een voucher om naar de tentoonstelling te kijken die er liep - maar dat kon al niet meer op dezelfde dag. Maar als we er wel gebruik van kunnen maken, zou het nog steeds als niet erg royaal hebben gevoeld, want we zouden er anders niet heen zijn gegaan. Die actie kostte Eye dus niets, althans voor ons. Een korting was correcter en stoerder geweest. En dan het drankje achteraf in het restaurant: prachtige ambiance maar wat een ongelofelijk onprofessionele bediening.", + "is_truncated": false, + "reviewer_name": "Wouter van den Berg", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIttAks6XymQ6ZEPsszxP4W4vn5qB2tuyat_199ySKFuqL5VQ=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "9 maanden geleden", + "text_full": "De buitenkant van het gebouw is prachtig en de architectuur van de binnenkant ook, maar de activiteiten, medewerkers en exposities (voor zover ik ze heb kunnen vinden/bekijken zijn echt sub-par. Bij een film museum verwacht ik oude camera's, uitleg over allerlei technieken, een sectie hoe je acteur/actrice wordt en wat er bij komt kijken, exposities over Nederlandse regisseurs in het buitenland, de prijs van roem, etc... Maar wij hebben niets hiervan mogen ervaren.\n\nBij binnenkomst loop je tegen een receptie aan waar meerdere medewerkers achter zitten. Daarvoor staan vertwijfeld een aantal groepjes bezoekers vooral niet te interacteren met de baliemedewerkers, maar met elkaar. Na een minuut of 5 geprobeerd te hebben om oogcontact te krijgen met iemand achter de balie dan maar op eigen houtje en zonder het scannen van de museum jaarkaart een rondje gelopen en mijn dochter een verhaaltje laten luisteren van een regisseur van een kinderfilm op een bankje boven het catering gedeelte.\n\nDaarna liepen we, omdat we niets anders konden vinden wat het bezichtigen waard was naar de ingang van een expositie. Daar wist een aardige, oudere mevrouw ons te vertellen dat we daarvoor echt een kaartje nodig hadden en dat we die krijgen als we onze museumjaarkaarten zouden laten scannen bij de ingang.\nOp dat moment was mijn dochter van 10 er ook al klaar mee en hebben we nog een rondje in de shop gedaan om te zien of daar ons nog iets kon bekoren.\nZonder aanschaf en een illusie armer zijn we daarna naar buiten gelopen richting de Lookout360 voor een dagje toerist in eigen stad.", + "is_truncated": false, + "reviewer_name": "Bert Wolters", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjU92EEd0JSepG3FkCR_YJRfRMlngcR2-4aGjIj0ZoiYaGYf6cC2=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Pracht uitzicht over het IJ. Museum is meer voor echte filmfreaks. Mij zei het niet zoveel. Beneden was het wat interessanter met oude camera's en filmtechnieken.", + "is_truncated": false, + "reviewer_name": "Maarten Kwak", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKupneUKBzJMBIqAb9u_zDWH-xq2XKJgnWm9YKfhMSxg1rVzA=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uTo-5pDwgUXPnqUDc4x1Vnwg4S67ZVjwA5HiKhQOHRSV3lxEoYCFv_TRZtTaLTciom93w3u9XKiA5UMt94V0ifxplXEgaKnvz0JPnexBvkIzlhA6yACans7vGAAzegXIdDDeD6=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v24Gy6P3x7A7fes5-2P4GiKAOwxQTiJyfWevJ-dV1p-yWeAo00QLtLQK-uNU3PAzCpR_FPlCIf-NIrCtlkbaGsTq9TROQi6Z_LKLWH6LKx1MBzwNNwrc6TPZDCMp4jsZ7wTBgjRA=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "5 jaar geleden", + "text_full": "De locatie is goed gekozen. Zeker als je hier voor het eerst komt. Vriendelijke dames leggen uit hoe het werkt. Restaurant valt tegen. Niet veel keus op de kaart van gezonde dingen. Uitzicht over het ij is top. Stoelen en beenruimte in zaal 2 meer dan voldoende. Kom hier vaker.", + "is_truncated": false, + "reviewer_name": "Al Bert", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVTGwHD6P8dgAFReJpZQhzEil3v4-Ecy8S1xOgvtv7p3vX9x3TvKg=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uk_QQy5yB-HhrDYt-CvvOzNsgmvvawfp9bK6qbmqgnMglobtMRRKLZ5cShBgsgSCkWw1W0C-qJU_mXajgXdAv4R9Fcf1YH-_JQ9hdr-M3dfNrkm_yeYwpeHusx40hkv7ei0DKj=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sGvLn4Jo9pWgYb3utjgISzFhD7grIcAGMNyO3NGgI9bKNPHLNNK6om7yvFfspP7eBv_epPoGaG0bmssQgYW9PLOPWaOsCOKSUVxKQdQD0A8V6AMS2zC3VKr6ZYZ2kYERRB0ijSFw=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "8 maanden geleden", + "text_full": "Ik ben hierheen geweest om naar de film te gaan. De bioscoopzaal is netjes en niet al te groot. Op de begaande grond is een restaurant aanwezig, waar je ook eten en drinken kunt bestellen om mee te nemen naar de bioscoopzaal. De kaartjes worden gescand door een medewerker voor de ingang van de bioscoopzaal. Parkeren kan dichtbij in de garage A'dam Parking. Dat is zo'n 3 minuten lopen.", + "is_truncated": false, + "reviewer_name": "Bj van der Jagt", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocL7p_MLdsJ4-gW3XkrWL3GtQUMX-jQzURkAHaIrN3SSbRxPPg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Film ‘De 8 Bergen’ gezien. Echt een aanrader!\nVanuit het Filmmuseum hadden we zicht op de kisten waaruit honderden drone’s die avond een spectaculaire show boven het IJ verzorgde.", + "is_truncated": false, + "reviewer_name": "Bart de Rooze", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWggiV35xSIGv2_e5GUpXAfPWPTQ5X4jk943nc0SX5_jhSF1u0=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38ub7kY2divgxrWm72r3JV6wJorcd0vGsgo7tGLkWoXMeDQrqQTDnKQylUVpEFg2rXumT6zAqAfDrFXqQiQKtv9iCd7Y5K6sAb_l8jOvm9SfFiQtXaB_RnjJJjc9w3TxIXKJp0pL=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sOKxHGCXfZP8hs9rnFRakQDBeyvNAtoGjQFtcnVCCrYhFsFic65hwh0c1p3b5Ksq2lBe7dmWJ_KTunt40N9Lzmauc_eF_wDBq7BPhfy3xghT_R9GJYPzqPTZIUrn5wKUWUik5s=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "8 jaar geleden", + "text_full": "Ambiance, eten, tentoonstellingen en (filmhuis)films zijn doorgaans uitstekend. Mooi uitzicht over het IJ. Door het opvallende ontwerp is het gebouw is nu al iconisch (maar je moet ervan houden). Door dit gebouw kom ik nog eens in Noord ;)", + "is_truncated": false, + "reviewer_name": "Luuk Tubbing", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVNR7MrQVO9FHsU6E-cxHINE6E8B3f8syTxv0_LJW1lOatDJqjS=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tFL6__BojLVCsQwsUKdLxelzPf9tSivlX-h8M2DgXjOlftGAM7Yl5ZPFJQu4Yrsn1lVLkJLnSUhoL6M9EzzgpI-FGBn2qEc8cfmlfp3isOftDbY3YmhW1ngSNsOcty123D9sDYqA=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uY56B2edHimFp4fTxQs8OPfXE2GKMNb5wK8f3Y61bGvVWhRc_Mj3Do57wRK2X_kn2_49UxwlzSzU5xURhLdPlJMZDC4mac7bXwtlER81viqmlBH4CkPdw5P_YuOTSU0YO6mMzG=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "een maand geleden", + "text_full": "Was leuk en goede activiteiten ook geschikt voor kinderen", + "is_truncated": false, + "reviewer_name": "Ilse S", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVdhNEueb_a50sik3ghGLR2S5RCDJ4IlHMIdUQvRiFPDxWe-uM=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Leuke en gezellige bar. In de zomer kan je heerlijk over het IJ heen kijken. Het stormde vandaag behoorlijk en wilde niet in het IJ belanden. Het personeel is vriendelijk, de bediening behoorlijk snel en de prijzen zijn gelukkig niet schrikbarend hoog", + "is_truncated": false, + "reviewer_name": "Slingeraapje", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVd_UHcSw8FzrjIwcwwzJGfTp50XKRwh82l5GCKjACmQOLbWDk6=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sbAxvPXhxq2nBIkncAoqHHSsFvB-44H33XOLiCxPervLq0qsnUqQ28wSOmoOccuALIqDqht3H-GUr06Q-QIBHTfYoxO5eyxfBP8tvnWtsQNEIQhNIlZJlfywqeudzKZhVwhmQZ9g=w600-h450-p" + ] + }, + { + "rating": 2, + "relative_time": "Bewerkt: 10 maanden geleden", + "text_full": "vast leuk, maar niet binnen geweest omdat er bij aankomst een besloten evenement bleek te zijn. Museumkaart website gaf dit niet aan en ook op de pagina van het museum zelf was het pas te vinden als je doorklikt naar \"tentoonstellingsdata\"\n\nUpdate; nu wel binnen, kaartje gekocht bij de ingang, tas netjes in een kluisje en toen het bordje \"permanente tentoonstelling\" gevolgd, wordt er ineens achter me GEGILD \"kaartje zien!\" door een vrouw bij wat er uit zag als een informatiekioskje. Geen volzin, geen beleefdheid, niets. Ik had notabene het kaartje nog duidelijk zichtbaar in mijn hand en keek wat zoekend rond. Gestempeld, ik wachten tot ik het terug kreeg... Duurde vrij lang, alsof ze nog iets van me wilde, maar er kwam geen woord uit.\n\nIk kan geen gedachten lezen\n, maar ben wel een betalende klant dus zo lang ik me niet misdraag verwacht ik toch wel iets van beleefdheid?\n\nTwee sterren omdat het opzich wel een mooi museum is, maar erg welkom voel ik me er niet. Dit in tegenstelling tot de meeste musea!", + "is_truncated": false, + "reviewer_name": "Free Wilhelm", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW9pVJ_Rs_P3mLq1ZlFa0b0wONoorLGNeMDDSaoYDd9ghiaUJM5=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "4 maanden geleden", + "text_full": "Prachtige plek aan het IJ. Mooie filmzalen. Helaas is het restaurant tegenwoordig op maandag gesloten...", + "is_truncated": false, + "reviewer_name": "Erica Bouwman", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUGBfStCL-KYCVaXOy00j1aYrlxfDIJAhO-bSbYm67HPdsnYyjZ=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Ik ben een groot fan van de tijdelijke tentoonstellingen en de goede filmselectie. Het eten is redelijk voor de iets hogere prijs.", + "is_truncated": false, + "reviewer_name": "robin tijdeman", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXGI3pbsai_AQwgbHJbxOfqD1Bbk9h6YlwODL3j7_LCmy7CL3o=w36-h36-p-rp-mo-ba2-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38trZglbwtD8uxzM8RVemVmpbZywkAX5_0DxYdvfcyd2cAqBrrQMJzmj-01bksHLgRlyvjqbVnY7I0sIXDCL2alkh_Yf1Cwnml_Nj6eB1McusbUr5S-4BTbUZ4bs4EbHDtDvWmzg=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uBeXOHA182W1FCM-ivNtW_kd6jmHkBziCrrVJslU0YGdyEVO7zAqLGdSp66qtvuuPcmY4wwmjMKwbDjXyHDr3akM1K74YyI9iv1PberJhjE_6eKFeBnKKs50sk6iwrKivfLqGc=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38skQ8XcHpiHUljZ3ZohPuY4dPhMNfTqO0dLnBJ9lLYij42laYgWFG5oMYt4AnE4zbDtNK14GH1isD35_Ri2wDOqAxQpQGlbm7HXqJpe-27RQoF7RlJ3iiFEk3AgfFg_CYBcNRZr=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uUvYpgcNO7k18tB-QCJYIvovjr6xkyJsmkwc735ZqXoEmYK5CveOigoPq5-dKV5JirUWq5Kui8tOjwuwmLdPJi2KOfmHZcueFXI4CEI66RxuUx_4V7BcWM4ofWY7mFAgRxQpFQHQ=w300-h225-p" + ] + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Heerlijk anderhalvemeterfilm gekeken,...\nWe mochten niet met een naar binnen maar alle regels werden nauwkeurig aangehouden zonder beklemmend te zijn.\nJammer dat we niet dichter bij elkaar konden zitten, wij van een huishouden dus. Maar ach,...", + "is_truncated": false, + "reviewer_name": "Frans", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJdrl87vFfG6xfyCpo_lBwx8qft3DRTKhGgT1y4ZCQonuHyww=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38ucXWtnOFYHgfwVlWabd-k1HG5hxr8htF8hyjBZxqT5JtkqfZ5RVfAhcsj8LFcxFrMadjDB0PPO_YopXBOCQbA98lI4WDjqXLsR3Bu7QHrQqKCrnWJti3QxWR_62qnK_keC_VI=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vF6yDVPQ7v_M8CVxOS4VYMfh66Q60P4NEo9Ug-LTw9GwrnkRMVbelDJeLdcdkYoPD38o12pIC6JM1Ur0pX_8RFwD6u6a3YrkmJSFuMQDocYfnTYaHKb1SFioTlfbLz6b9xCA9Osw=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Zeker de moeite waard om langs te gaan ... met de GVB pont ben je er zo.", + "is_truncated": false, + "reviewer_name": "Rob van Osnabrugge", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW72up2mbiKNE8G_UwqQwhrl4xDbMNukkZD2KhslcMX8r2nB1U7Nw=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uYQyFB1XLdo2Zlfainkc4u86BrouvrTZFnfEF1woyTOt93VmOdbhEreP2fdwElmrOuJQYytOqsAfnMXtx0DG_Lag2KwgVgIfotba09hEHpZ9EWBZaerfHU8YefWzQXad9pVPyj=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38us2dVrRQAu2KfAzZh03aJQs66A6ocn7KaZESUjxoIK1XZeuzAk496WDpNvfZBd1_kkeG10JzE2bFHrTCUNu3KiNzi3aJWOIRBFfF1JhirO1O4MTl20YyD0vYw-X3Aho55XAQkdAg=w300-h450-p" + ] + }, + { + "rating": 2, + "relative_time": "een jaar geleden", + "text_full": "Ik had hoge verwachtingen op basis van een review. Maar ik eerlijk te zijn viel het tegen. De permanente tentoonstelling was zeer klein. Is er niet iets meer over film te vertellen? De huidige expositie over de jaren 60 cinema, kon mij ook niet bekoren. Dit was iets te kunstzinnig naar mijn smaak. Maar dat kan ook wat over mijn smaak zeggen. Als je een museumjaarkaart hebt, dan zou ik zeker aanraden het museum te bezoeken, ook vanwege het mooie uitzicht over het IJ. Maar om 15 euro te betalen, voor twee kleine tentoonstellingen, dan zijn er andere musea in Amsterdam, die meer waar voor het entreegeld bieden.", + "is_truncated": false, + "reviewer_name": "Tim Grob", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJp61LisZuob44KeMnux2WsobV77-TLT36Aa5_rivh88LlJpw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een maand geleden", + "text_full": "Wat een mooi gebouw echt een film hotspot.", + "is_truncated": false, + "reviewer_name": "Rob Sol", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXS889eY0wB3KGRTAUFwTaKZYTIhV7pDwuxL42Bi7qkk0Td_Uo=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "9 maanden geleden", + "text_full": "Online tickets gekocht, kreeg ze in pdf. Kwamen met een feestje van een 14 jarige jongen met een hele groep kinderen aan, zeggen ze dat we veel te laat zijn omdat de filmtijden last minute zijn veranderd. Geen melding of iets daarvan gekregen.", + "is_truncated": false, + "reviewer_name": "Lloyd Snieders", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIZfPhNlKORvxm-01YOF-k0OI8E4VJ8w4aDY8tyMcsKX9rCnw=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "11 maanden geleden", + "text_full": "Gebouw van buiten bijzonder, maar blijft binnen weinig van over. Permanente tentoonstelling wel leuk, maar ook erg klein. Daar kan veel meer van gemaakt worden. Tijdelijke tentoonstelling over Avant-Gardistische film kon mij totaal niet boeien, niet interessant of was te vaag voor mij. Van een museum over film had ik veel meer verwacht.", + "is_truncated": false, + "reviewer_name": "Jeroen Vugts", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIaXLDx1OAvQmq4N_8RJpX2bHk1nW091FcjESSxW7drZc1mJw=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "8 jaar geleden", + "text_full": "Prachtige locatie om te eten in een bijzonder gebouw aan het IJ. Jong, vriendelijk en hulpvaardig personeel. Het eten viel me iets tegen en de prijzen zijn te hoog voor wat het is. Maar het was een leuke ervaring.", + "is_truncated": false, + "reviewer_name": "Abraham Custers", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKOIBB_VMCtc3fmwIY-TD7YfBlDGmvcKzlFTIdjeVEaFwXbCQ=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38u3s9IwaO22NvYQXyGyBRPqF-z_gaR6hUUxE15hsvjJdXAijkNSeHD7v1oGch6WJRO1nfUYucTXm5RODVlIDKxJCt3ix8SxEcHTl6ujpScTvdDni6ll9g7qW5N6ydWRcgAQ22Q=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vc1gYlliHySl-VeElP08YVBcFTaKYxzKeu9lcwWKL705n3aYsYWkJFu8HGdZejEJcHUAbJyLwKWvV4xcGwwY0TsPl-rQN4gzmyJs1n5ZkY5uf8aFUUjpK9nUv4hueTxEBgvouI=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "4 maanden geleden", + "text_full": "Altijd leuk om met kinderen te bezoeken. Horeca is prijzig.", + "is_truncated": false, + "reviewer_name": "Danny Andreas", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjX_Wlq_W8eWlTUrYEKUolyYfS3GFxF6na2BZKkWtmpeG0VOxSt5lw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Wow! Ook als je niet naar de bios wil.", + "is_truncated": false, + "reviewer_name": "paulien de groot", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXGEHvuRmey0iPwW3WmqQvuK6GxKTKUwZpBqBbpT0RsjbFfge1U=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38u-yW0jQBUhSLO6KZKuH39lOwAttLY2OfBS3h9G5Tap67enKpLkkTne5eIv_cbPa1cpyut11naayhwbFSEljTU_cZcdCOyXGsIoLH2nB5LtuuXVahesS2V_KSuUi8O0Ms_XUIIg2w=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v4C7KCD3CW_1QlP73NZB_jSghZ0Legyoe5kiaUjTBteYeB2ZisJ7USQNOFcZHuXsWdARIAwolmcHDEO1w1lT7ByRE_cTMBer46EOyr7kvDKOR7AMFdS8Ra2U2uXVoPmP7TDZGR=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sIZtbeE_RyLJyWaM8t5uRZ3WERCyJ7kEmj-CssTxEWeW0tHMjzaBUgs6uqNQbyg21UULccTwD6fskkVPhXq8U5EBFOhGq66B3POHH7G0Pv9x50jivwPxm09v8tQN1RR8v8mrRKuA=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tlWNucem5iYGMr5-jFFK4Ols0vyZjjkpSy7mrEUxoYRCxFxYcJtwjaJgTvGDL89ZKdqCxyCUeoFW7WRkqQIKNuiXaYNqWoSVxdo1qugLejfiEFN0MIn9CMRnTLmpmHxkuqxLU=w300-h225-p" + ] + }, + { + "rating": 2, + "relative_time": "7 jaar geleden", + "text_full": "Leuk gebouw en prima zalen. Alleen die bediening schittert in deze voorstelling door afwezigheid. Ze lopen zomaar 7 keer langs zonder dat ze in de gaten hebben dat er een klant is die toch echt graag iets wil bestellen. Gasten bedienen is ook zo 2007!", + "is_truncated": false, + "reviewer_name": "Jungske jungske", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXqVti5SqRhDiZe2LTuUWKBwYNqaAXvKEZmySlCnBKyo_NJgg13=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t4ZTvSFc4MZx1Pi3JRQl1fy0EQJRU-x1kTfaMiCIdclE5hvatEfij8oMPCRvkY3pxw5KIQefBN_y6b06s-OGoBDmA2h10aHHrLqoMSEopNRxP3FegaoRxNn7ea9rQMBsrZUvj0=w600-h450-p" + ] + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Helaas hadden we geen reservering voor een film, waardoor we geen film konden kijken. Dit was jammer, omdat de filmervaring duidelijk een essentieel onderdeel lijkt te zijn van een bezoek aan EYE.\n\nWe hebben wel de vaste tentoonstelling bekeken, maar eerlijk gezegd vonden we deze niet echt de moeite waard als je er alleen voor komt. De tentoonstelling is aardig opgezet, met wat interessante informatie over film en cinema, maar het voelt meer als een expositie in een bioscoop dan een volwaardig museumbezoek.\n\nKortom, EYE lijkt echt tot zijn recht te komen in combinatie met het kijken van een film. Zonder dat voelt het wat mager. Voor een volgende keer maken we zeker een reservering!", + "is_truncated": false, + "reviewer_name": "Bas", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVBgScXfIjcipGUlkpQUj3uXy2Fw8r_VVS-AfRhdVylxQjulI70=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "5 jaar geleden", + "text_full": "Eindelijk een keer het IJ overgestoken om dit bijzondere gebouw te bekijken. Er is een kleine tentoonstelling beneden over technische geschiedenis van film, die ik wat klein vond. Boven was een tijdelijke tentoonstelling over het werk van Chantal Akkermans. Ik denk dat je een fan van haar moet zijn.\nHet gebouw is prachtig van buiten, maar een onwaarschijnlijk doolhof binnen, zeker nu met de Corona looproute.\n\nMocht ik er ooit nog terugkomen, dan denk ik alleen gebruik te maken van het restaurant, dat er niet alleen mooi uit ziet, maar ook een prachtig uitzicht heeft.", + "is_truncated": false, + "reviewer_name": "Daniël van Leeuwen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVlhik_BE_hKxOVth2gfb4P9fyCiSW9GDB3ndh4yufiSe2hWBUd8Q=w36-h36-p-rp-mo-ba7-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38urfMO99rDSa-e7-MYMfSofNP5rx9wLnSazLFjO7lPHRonGMv2sBcuHxEMYJfQmjGIPSovmKo8eh1kuwZwJl_u4JwzAkgcbDglZZ2JTD0v36xFCO3JPzAgcroRQLnbdIyNVnJzPkw=w600-h450-p" + ] + }, + { + "rating": 2, + "relative_time": "11 maanden geleden", + "text_full": "De permanente tentoonstelling is leuk maar erg klein, zou zoveel meer uit gehaald kunnen worden! De tijdelijke tentoonstelling (Amerikaanse jaren 60 Avant gardistische film) is wellicht alleen voor de echte kenner, voor mij was het veel te vaag", + "is_truncated": false, + "reviewer_name": "Elisabeth van Pruissen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIv31zgK2PI9QyN8wmcvwfPVmHgQ-N54pepazO7ubjzZTyysA=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "9 maanden geleden", + "text_full": "Fantastische plek en was ooit in mijn leven een tweede huis bij wijze van spreken. De film selectie is geweldig. Het personeel is vriendelijk. Ik ga altijd langs het winkeltje en het aanbod filmprullaria is waanzinnig.", + "is_truncated": false, + "reviewer_name": "Dennis van Weezel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWPvU04L1rZlR65c55ijs5MwUJ5fz5ahHocLY4jrlqvX5rjSFCq=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "7 maanden geleden", + "text_full": "Prachtige bioscoop, met geweldig uitzicht op Amsterdam!", + "is_truncated": false, + "reviewer_name": "Mensje Koerse", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocImYE5Cnrk-7xQ68onscp6zH2pWaBurNYp87ag0w7kpBgqiZQ=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t8nJUQJy3NBv0-x9jE-CDnXPHjdhd9FFNoExYs6dPLSN2OEIOjEGiS5qkhk3x09YsSPPR9yPrm_eMcDKK_vfFxgLRsut8smNK6keb722F3RJz03xC4R4qgwwZ0AL5-rj6StH4agg=w600-h450-p" + ] + }, + { + "rating": 3, + "relative_time": "7 jaar geleden", + "text_full": "We gingen om Ryoji Ikeda te bekijken en beluisteren. Voor de liefhebber een aanrader. Verder ben je wel erg snel uitgekeken bij de vasre expositie, zeker als alle filmcabines bezet zijn.\nOok heerlijk geluncht in het restaurant. Werden alleen een beetje hor en dol van de trap op en af rennende obers.", + "is_truncated": false, + "reviewer_name": "Paul Kösters", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKk80wJcEjE8Dw_dVLHuhsuUwq1iLcRs45x712bONVsOdcmTZI=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38s6zE6QzAS6QoTC8S4DF4k-BuSrs1zy31xjoVgFKeMQ8HhoS-c9KKz7DhBDEn2fDVDzC4OcXrDwhkBghzlHCypCAjoGIScwSdUZyCzGgEogQxeGwHrD2VT7SBwmlV9ItPDujoY9=w600-h450-p" + ] + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Erg onduidelijk wat er hier nu de bedoeling was... miscchien had ik het moeten vragen. Veel plekken hadden we geen toegang. Waardoor ik met een half uur wel uitgekeken was.", + "is_truncated": false, + "reviewer_name": "Maria Van Achterberg", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW93bAlDZy4Aqb-4buvojg1gJBV5Xk18gYx09lDtVney7JN7kxmbQ=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v1B_8KF6ZW6AAKHV1ZCIgbWHHZ6Jb8KlysNGWxCPf1yeBJVTzHOlTypuNzwTo3G2NVO4pb8qGEIrGzDt6PgfIcTqRDbSbxNjhjfCm951dUQn68_WE7g-WW-EpXrbzBvrzqYb4kNQ=w600-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Geen bioscoop maar een filmbeleving! Wat zo bijzonder aan deze plek is: de grote ruimte en de wijde blik op het IJ door de raampartij EN dat mensen hier op de fraaie trap zitten met hun drankje. En o ja, de lampen aan het plafond en de interactieve lichtwand zijn kunstwerken op zich", + "is_truncated": false, + "reviewer_name": "Natalie van Ham", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV5xo2sW-EDY9WNByXEeTAi9U8FwtK0L-8DrB43fBZyBCqMiJP7=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tJUFw6qOg93fRY1p4whnHLuiGoYu2_ZIOEQwxMjItvaBbf9pZiqrNtV1kjeusiQdh5_2X6EP1o9jUeX-C-ZMsdw8diVc2QW7nM0pdiql_I5SqpfSg06MYspDMeA5s1sohLjCdc=w600-h450-p" + ] + }, + { + "rating": 2, + "relative_time": "11 maanden geleden", + "text_full": "Het uitzicht is geweldig. Maar het eten en de bediening is niet best. Ga er vaak naar de film, Cinevillepas. De kaart is klein en duur en de bediening is langzaam en ongeïnteresseerd.\nZelfs een tweede kopje koffie krijgen is een uitdaging.", + "is_truncated": false, + "reviewer_name": "Eelco Freriks", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLg2EbbSgDYwPRcNwUlLIv2doz7cX9NX2pL1N3PXgw868xAqw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "3 jaar geleden", + "text_full": "Leuk om eens andere films te kijken dan de commerciële films die er in de meeste bioscopen draaien.\nVooraf of achteraf nog iets eten en drinken met een fantastisch uitzicht over het IJ!", + "is_truncated": false, + "reviewer_name": "Claudia", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWaCuY6155DHxzLPrdUo0Dv5C2Pz02yVsWLpy09YDdCC4W84BJ-sw=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sai7Ko5jl_yWI8X8wwzIJwSw1XaGJOZPwEliYHLOE9rcVmyNwQnzZFdTH7_doftBd67jEp3Xm3bHKIRMVdcVJy3iLsZOcZrGVJlcwxV360RuojOCut2V_7VqWJPQ8NHRjIIuI=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38u42ItNiS55hF6wuT2-nba0g9H94_fDDZDezLbO3PQCw8uLrfz3fzIoiQolIwlUbfYsUcsy3oirJm4ok6XFLGI927MzW1IbClZX8db65P2dx3oRwdd8TjyxkIZATl9rFhloRu_KIA=w300-h450-p" + ] + }, + { + "rating": 3, + "relative_time": "8 maanden geleden", + "text_full": "Weinig cohesie tussen de verschillende onderdelen. Voor kinderen wel een route en bingospel, maar veel exposities niet echt interessant voor kinderen. Wat wel leuk was de mogelijkheid om zelf een stopmotion film te maken, maar ook zonder entreebewijs kon je dit gedeelte bezoeken.", + "is_truncated": false, + "reviewer_name": "Jasper van den Bliek", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV3YiHyHk1nKW4ye1mzqulaLbs_RRBcmv2wdTO5GQ_m7LXFxeM=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Klein museum met een permanente tentoonstelling over films. Niet heel spannend. De tijdelijke tentoonstelling met werk van Albert Serra is vooral ontzettend vaag. Ik heb mij de hele tijd afgevraagd waar ik nou eigenlijk naar zat te kijken en wat voor zieke geest deze film gemaakt had. Het zal wel aan mij liggen dat ik het niet snapte, maar het was echt niet aan mij besteed", + "is_truncated": false, + "reviewer_name": "Jeroen Tabak", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLj4xVLnfB22PAKFwsA_M0RdKyvkYwJz_ubw1_bZzPjzLjfTg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "6 maanden geleden", + "text_full": "Mooi museum, sfeervolle kleine filmzaal. Goede faciliteiten voor een kleine conferentie", + "is_truncated": false, + "reviewer_name": "Marnix Labadie", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIxLlQDGymTNVjLNffukEmP4P4GkfDCVGi8ggZ-yIVuuNFtWw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Wat een leuke bioscoop/museum is dit zeg!!", + "is_truncated": false, + "reviewer_name": "J. Singh", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUj0y18QiUt1rCP-fbHpf3VtvTbL-m7njFJblu165OyQb9jJ3CPdw=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uOJBoJxJVnziCQlCQAv5qYVKkna226d4qDXqKU7rveHyXHHty_VMLqsiYweQDC66gF37YXuWz1wdXhARwr8UT39CpPsLIO-dBjMq2T7iK0p4-AmQrlDJo7u2JFm13iJHCNj85Wtg=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uS9j4ZVAyWbLHuJ67KG6qKBKRDd4hbsPyPLAwQyapgXDxqmUx6LEu-HdGWvecbDXH9DOcajpEEmi3czRlU3c0syX9U_bH2ZSjlYNXCfOPHBSRyxpMTIAKVGOVtAl68_jh_BVps=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sENLDJZPSV-KxcNJVH9suaYqdkVp1cZ4LqV08JaDq19l1utN12D_mIb8AP9FzgQdjULSf8-gFXhBdBY3RKg6YPZCiP9sxSfbXMZPXssNjH3rVuRVH0qaJJVzAyGyusuxlczeWOGg=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38usvoBY2ZnMVqeq76NeAobI32HnAccuCcf4tiBMsKXBh8vn1Hafe2tuGVdFRSJSusgyKmVvpl2UI4jY2TOueP5qG0rhsa1N2l1mgueO-OM1GbmOa5wmw3Sd0EC9EgOnOpwrCilA=w300-h225-p" + ] + }, + { + "rating": 3, + "relative_time": "6 jaar geleden", + "text_full": "Het museum zelf is prachtig, de exposities spreken mij niet aan naar het buitenterras is geweldig", + "is_truncated": false, + "reviewer_name": "Henny Zwart", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVq_eeTqdu8h3P1hOJOO3AfUJxNLrl-sK1VUZ-wj2K87a64ReLprA=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vax8Vxww-7xrssvZKe2Nd0ViUTVo4QEkXfOEi5Onn2yat3mNUQMHznn-51EINBo_KXqWQwU_RgQ7FhexMkevUHWrQbsw9gGY1ZiwWQn9WC24k8MIG8k8tn0wOjjQ6iAE75t8CM=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vpxfp5rKno0ZSTXUW3dnr8XiAMppntCX3vqaByKJxoo4jI1yEXT3n8a4vUpwSDUblzcA4-fRalIjQWZuuZOYchARMaZ_loQ-91HBBs9ukjVHSZP3sk3JBcZAXuRWbAZmxj8FAowg=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tYcUD7JzPFoi1aLaCsuhnJuqIZ_tKbeME-mQrrNY6kPKG4wv7I_QKBsIZgw5w6ptdkTw1JA-JHth9I6cJHTmxwe8sg2flNdlkL1JXV9mijgyoIK7-_xAQ8u0Juroxcd4-DUkBU=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vhxOezAccnpZzAcrv4pzMycF9JaY6-tXNLtm3sccdWsjBbhzGFHT2gizWt0fXRC4yTnXzZsLgkU5YdWl2ECqZl4GhNREXqewLt4jYelpt-0waVFx6xPJXGqVtfpqVlEX_tkobq3A=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Eye blijft mega mooi gebouw én plek, de filmzaal is super comfortabel, ook qua geluid en niet te heet! Nu alleen nog de medewerkers die kaartjes scannen, uitleggen dat een e-ticket van het Eye, toch echt een kaartje is! Nu werden we met ons e-ticket weer terug gestuurd naar de kassa……", + "is_truncated": false, + "reviewer_name": "jaap roorda", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXJK74eIVMNWTZ79Zsky3xwKPYFH-Tbd4JHOWVh0Spwx8BkYb3u=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Geweldig gebouw met steeds wisselende boeiende tentoonstellingen naast vaste collectie, een must go als je in Amsterdam bent", + "is_truncated": false, + "reviewer_name": "Sara Keymolen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXiGCLxkhv_kg3HD7Kc7UAHemYR8IY57KDBiR5x8jkEPNxH8y6g=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38s9eHkLV8kwD7bn9571AZeGqM77IYXkc--WwOECnEATCLXKZRs9D4boa-sGOZrAXj2GCllkMOZYppbgPnNSNFuO0p7z2A6T1MHhpq5viCXpTSEqv8zKzVp_JI5bNjodptzuNsOO=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vJY7g5_2eoxtctgu9u3qcLGWE6oAdGewJTTwItVefmj-SgvyzyKvyjuy3uJ5R2FyFnD8NQLQmvRPv70CjxXUF9pmAjMk8e5_eXwLUkEeDdKmjusVM2RI1s-n_j77s4BEnA1Mw=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Fijne sfeervolle plek ligt het Eye Filmmuseum. Op een prachtige locatie genieten van een mooie film.", + "is_truncated": false, + "reviewer_name": "Liesbeth Leijnse", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUlU2EMxTbUItsc3THuTuQDXJsK1i7VrhGVqB9EgmeIxV8PQYgUmA=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uBTlvOLsRXktYzD4ywTX5zbVtktkF1nhw180nPJTFBV0FxYfzvcW6y-iGk9iSX33IznE-y0AoPU63g0WPgStMquoikP0pw4YDjcgdVjX3-CgINYbMtQ993dF8W6o7ETCJcF1ve=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v9Fj5OxcnHAHGF49ITcjKG5M_mvMa3fVlgot1c7RuR8pEsf-uUdZ_RTvIznTSBvJye497Cm29q6gIN5NmsPaYeN8ASWlX0fWzufv4o6GbKWGneDH75TNovirkxbWZEo4Wzc9-t=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Onderstaande gaat over augustus 2023, blijkbaar mijn recensie destijds alleen op Tripadvisor geplaatst maar nu een jaar later kijken we er nog steeds met veel plezier op terug!\n\nIk bezoek het Eye Film Museum en Eye Bar Restaurant met regelmaat. Door de altijd positieve ervaringen heb ik ervoor gekozen om onze trouwdag af te sluiten met een lunch in het restaurant samen met familie. De tafel was prachtig gedekt en het personeel zeer vriendelijk. Ik ben jullie ontzettend dankbaar voor zo'n mooie afsluiting van onze mooie dag!", + "is_truncated": false, + "reviewer_name": "Lovie Lova", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLtmMglCdrVSt2fWigTxYgakDyDAkqhD-_o_3gItEYqXjE4vA=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "10 maanden geleden", + "text_full": "Review restaurant: Prima koffie en lunch, en natuurlijk een prachtig uitzicht. Ik vond wel dat de vegan opties op de kaart erg beperkt waren.", + "is_truncated": false, + "reviewer_name": "Lenneke Sipkes", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUcwEvmkUf_liiqiKIiHCl37I3zrpKjlmG0Df42mb_ktUZQNhFZ=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Denk dat de meningen verschillen, maar vind het gebouw wel mooi formgegeven, vooral binnen, met een prima akoestiek en prachtig uitzicht. Vergeleken andere plekken waar je met een cineville heen kan is het helaas wel duur (zowel tickets als drankjes etc). Ze cateren ook wat meer op het publiek zoals in The Square - 2017 Ruben Östlund. Eye heeft dan ook nog een rol als museum/film conserverings instituut.", + "is_truncated": false, + "reviewer_name": "Gideon Saelman", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIpqanTffBxsIpNd_wcem1um2bG1fRjjkmB5b_3iAvmkGQ0kA=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een maand geleden", + "text_full": "Top locatie, fijne zalen", + "is_truncated": false, + "reviewer_name": "Annelies YLLW (YLLW)", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVFkU4mSZhRlbLWzk7AjuuqnH1Hr0XgPwN06LeesvX_wFaYc-zgdg=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Mooi museum prima restaurant beneden een boeiende blijvende expositie. Boven wisselende expositie.", + "is_truncated": false, + "reviewer_name": "Gerard Bakker", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUCUsb4M3lLOMM7dxHoX5Rda3QXw0aiTi_KyuHFWc49tX3A8IQ=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tXTmrL7Sz9FsrU-qyk4YDsktu_IrNdKx_LWgIEqp8kYMkfJmNhBP1UHcGNCBZxSh0K0TNi0CPBxZIOf6KLZ_54pyCrJ5gJ2SNWXYThr1PJ802Fjg3pzRAuqmiiyrEIgz8S8M67=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v4UVwBbWv7e9knSGYbnQYexxug1RESInWIz88kn0PgEHU0WY7O1Rl1_g9rkmDv0K9omyqKL_i2XnziurOxSFvlEhEEYjCgIc2T8_FbF0OCze38kw5lQk1WdZNJamzen9IW0Uc9=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38u2uac1C0OoB4OzTDeKQIqxp4sn_2L0wLQ73IvUvWXM4-QYdwNM__KnweH6Stxl6Fwmk8rDEAYxeWo2pRJ0LQgRq5C4zQCkOZvUxLCH29LfRCC6o0sZkHokcEQomuhKk0YEM7K4=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38teuJI3uLpHsL3DR99CEByP-IWdAFx7tRTa6jb7G_7NjjjvWKNBYr-knemQsBKtja3vRF2-fD52RmPxc2_HW82EeEUz1Ijf3UElxMbUNK70fdxdQqLeADZIcGRxh2SvoKkPjSvu=w300-h225-p" + ] + }, + { + "rating": 5, + "relative_time": "9 maanden geleden", + "text_full": "Ik heb hier een lekkere lunch gehad en ik was blij dat er nog een tafeltje vrij was. Het eten en de bediening waren super.", + "is_truncated": false, + "reviewer_name": "Henriëtte Wullems - de Lange", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXwjgDysh3eB1FAdvvu_S4yCBUjfSi0UIpDuDunhI3TkAdS295k-Q=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "11 maanden geleden", + "text_full": "De kinderen hebben meegedaan aan een workshop \"tekenen met licht\" en daarna hebben we de film Niko Voorbij Het Noorderlicht gekeken. Dit was in cinema 4, een mooie knusse zaal. Er is nog veel meer te doen in Eye. Dat ontdekken we graag een andere keer.", + "is_truncated": false, + "reviewer_name": "Ari", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW66JYf0tAsK7QKpbT-kBC22jwryTuTBfF6OWGA7P6BxQbhjmdp=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "een jaar geleden", + "text_full": "Vaag museum waarbij het onduidelijk is wat de bedoeling is. Klein zaaltje met permanente tentoonstelling, na 3 minuten gezien. Tijdelijke tentoonstelling was niet geschikt voor kinderen. Dus toen waren we klaar. Je schijnt er ook films te kunnen kijken maar hoe en wat?\nDe winkel was wel leuk, vandaar die ene ster. Binnen 20 minuten waren we weer weg, omdat we er 5 minuten over hebben gedaan om de kluisjes te begrijpen. Niet gelukt uiteraard, en we waren niet de enige.\nAlleen aan te raden voor artsy Amsterdammers, normale mensen slaan dit over.", + "is_truncated": false, + "reviewer_name": "Erwin van den Heuvel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW7exVDlIUShzvN5FoFDgNlJj7K6grD6se8HvybgvVWLkA9CwUc=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "7 jaar geleden", + "text_full": "Gewoon gaan en zien wat er is! Beneden is er ook genoeg te zien en beleven voor jong en oud...verder zeg ik niks...je mag het zelf gaan ontdekken!", + "is_truncated": false, + "reviewer_name": "Foodaholic Amsterdam", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUpo__YkrVubRWo8w5qHUNFdq-CU-MKt5ZbF_n8v_s9qvzJMD5r=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uu1IBBy7crKr0Hsx7pqUUeILXU1HZUhxyave17bnKg_weGmToqbxnOsOkO_66gWctV8QdtouGia63n2IdqSha_YUUTEQSe9aRcPn9R4Y3oeDY9YQnk1cAJYhhasRf8DoD1zBkN=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38ucFYWtDbQjwwWbUgv953exwLgOmHIW1ebgCbchbaNdCy9Kim7HErvuCyLW_wfH8g9WWpz_ycRfBmetw4_YlpOpKs_3R41YwKPMcbqodaA86nh-4BrPnjc-VNnZob5Gcclw881Z=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "Bewerkt: 8 maanden geleden", + "text_full": "Mooi museum met interessante wisselende tentoonstellingen.\nGoede horeca op prachtige locatie", + "is_truncated": false, + "reviewer_name": "Co Heemskerk", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWwUef6BC6vS_fqfTCASQ9TWPHrAdBXZ5X8p23HBeECoBxRGOeijQ=w36-h36-p-rp-mo-ba6-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sBGVfFJZEoKOFRPq7nLfCsIIpJENH1U-mDv7d50GsaptRil8_sm1BdWtrpZVki0vsD9aGfo5cx4AuA8aiGIfNEVf-LXGVKvPdQpjTr10h_fstbUV7XCKog72FAX2t8dQ8wQlmp9A=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sreXkyBzEBW067hBWvbe6mMWk84Jb1j8GmAO_0UrmiwSBEFN-1zwdMpFdMUyCpRT4Vxm0UkbFwLo9ciixyy5SXk1n174INdBNbQtVWOBRuHqT06KVkvxN4CUc2Ps0sVGktyFsz=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sndtmXq8sDYcI-X2SkjxXZpkmFmCEdtPrsP11oPxfr68De6CjxKspcsLL_CnhbEiOGCEduMrueNPyKwq74ht8eP_0seeMrCPPofqEizBbC9Cls2Rh2mOX05ByTROHEnHe4zC2u=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "Bewerkt: 6 maanden geleden", + "text_full": "Mooi uitzicht over het Ij. Alleen geborreld. Lekkere wijn. Bediening wat traag maar vriendelijk en in het Engels", + "is_truncated": false, + "reviewer_name": "Nv A", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWnBWjGLDvV-dMfzxzngBujDWa76TtkX7XR6SAcWPaaZgrXTlXuog=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tbJedIkcC8_G4RsOl0a2LVhMRGd9Vu0wDmERkyCZ1nNFvk5n_QLpNZq7eqd08S-47RHHR6R6C2-dPLtw0_66UD5mFHhGwY5NLqjSvycZ82T0tAqlbXkuzKi6crbl-kxF8WtglaGA=w600-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "7 maanden geleden", + "text_full": "Blijft een prima instantie.\nGoed filmkeuze. Ook voor kinderen geschikt.\nPrettig terras en café.", + "is_truncated": false, + "reviewer_name": "Joost de Vries", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLjyQIilkz4AXScYtWW1myODnL-Bqb4JzGXMCYVtPhjjVfQ=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "7 jaar geleden", + "text_full": "Hier voor een presentatie geweest van het werk. Mooi uitzicht en makkelijk te bereiken met een gratis pont over het ij", + "is_truncated": false, + "reviewer_name": "hanneke kohler", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIK3km-O0m5z_YNuMBEvooQxPprTFC8tscfrp4k8ZAJDzg0Kg=w36-h36-p-rp-mo-ba2-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38thVoLN8XZJIIgdcz71noaErQuO1KTYDRKGPyP92otoc04j2MiYOX7nO1JiQ3G9pBfKbWzhGS3RCS2ooWCC6yAA0BhRnhGcjis389xnAMTyD2q21I937oFV73D4AjgP_YcwuDbAxA=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38urh0hRxVVS2KdAOC1GQhdLxIHZW7_vzDVL4hntVaeDvO5Qb_tCzXqc4nl6KyogMtudfdD4DCZq_xp2c5DUDir_oqmhP9Ka7royFwjbcyaEkc735Z_mXGXWBfQnG9F1RrK25mR4hw=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "8 jaar geleden", + "text_full": "Mooi museum op een fantastische plek. Klein, maar interessant. Ook leuk voor oudere kinderen !", + "is_truncated": false, + "reviewer_name": "Irene Haslinghuis", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKQsCnxJry2jx-roUqiXKoZKh2vi_IPGz1d09osqv7XPup62g=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vEld_9wAjYmyxBosJXelP1JQ7vZgSLj4Z4qhZWYHgq33k-W5bY_zNG8RVfbgp36VHFQBnHW5gq9rz9Qii-FkmkCUrBTT6rneUYTzeL4YpDIlSwVdEJGubMPbLqi2VAuYymhBU0=w600-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Museum is klein maar fijn. Het hele gebouw is fantastisch: super uitzicht en je kan er ook eten (of in de buurt, meerdere leuke restaurants). En natuurlijk films kijken, zowel modern als klassiek. Simpel met de pont vanaf Centraal Station. Aanrader.", + "is_truncated": false, + "reviewer_name": "Jacco Minnaar", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKgESAq9l1hAxAjPpkzWOKktsU27NTHWMMhkO_7XQtuuL-GdA=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Ik was samen met mijn moeder naar de ( mij iets te lange film van 3 uur) met voorbeschouwing over de maker van de film en het tot stand komen. Ik kon met VriendenLoterij kaart goedkoop ( bijna gratis). Ik heb me laten brengen. Scheelt weer parkeer geld.", + "is_truncated": false, + "reviewer_name": "Maud de kok", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXBQweehERJeR_M8lCgboZ_xWS8vtlUcv5LxA7Z5ephCghpG4iP=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "Bewerkt: 7 jaar geleden", + "text_full": "Heerlijke bios, goede stoelen, mooie restaurantruimte, leuk terras.", + "is_truncated": false, + "reviewer_name": "Miranda Vos", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXpwrFWSLE1kKXk6797F7w66NmpxgdYGSKmHdYif5sl2kKRtvU=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uJgWQA788NIQIRNdOZx59GsIWja9oVhTrNiaOs34llpNvT9X8OcleLy0T3U8MuTLOsCLUfHNOq_k2hzSkv34u-Ke0ujCqapehVPs3A4jpmHsoJGUcK8LpEWM5e9LQf_Kk8iL2Miw=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38u9UZzY-63Q1AwrOhp-1toBgZ7mjOPwcnMJZS8YCos9UK0_khqbeMrTlBGh4BKc9-hf8dJOerT_fVJ8NzmZOgu_JD42B3a3LazmqT92hHsh4T1tYYvSkpVfXKFBg6uY5wSBvpiZ=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tPaXqt_eoNiYZDHFPowP8qXHoZwgU31Eo72v0tGW1fvgSpaqOboe8uSy4KjnpV4s1sTWLdaYv9C0D660Uiup__xaJYPC6n9wz7vhpXkPIvYA54-Z6H8hNR5adW2lMg4D-dIgew=w300-h225-p" + ] + }, + { + "rating": 5, + "relative_time": "6 jaar geleden", + "text_full": "Mooi gebouw en mooi uitzicht. Leuke dingen en je kunt er zeker de dsg volmaken", + "is_truncated": false, + "reviewer_name": "Paula Reerink", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIoIKVlxb67dubfA_3LSDWO8_NSlUSsg0p5PUl66J2rwKfi=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tOO83YkQRuKrItc2Fqo76H8_hV1Zc3ycP9zSLu8HuxP6A9fXvcIySL0CA_KcN3tg3CyACu7EI7YpDze5vq_uQ8tjqH_JB1Xo6L_V5Vc6J9GD2dxvCN3dOPxw8M207VsliIzsy6=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uJ20JFhumjItJ3CzFuE4d4NGSzMBSJtJiu3GgjR7IzfTQuXR6jr2PChkKMkp3qkgGbOj04gbOthYSq0BIbEGVNwPnG9iC-AB9_7YJQw5s0q9kKQmouVG5c4Rtc5qDKoHK1qoGE=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tC2MLyAv4n-r4UObPRAEHNH6JZdNEGEXLBsTNozHtADdPRvphFiU8_Tcc1NSpK9wHeW8M3ywfMBT_Eas5qxy4068UMGflJ0hdU_VrVs5LgDpIQ2HC2xL523V0yMzTXlA032UdW=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vAIUbWoDVLguhANX9jarsLTHAadHv0iOxqX9oUUcJMXlo4MQ8pDSIf80X-9Q7kmFwmxI8VlHWpVfl-sBJg2wTr2vRB-GUXismF6-uhkvwJnAyzTc_7B4lDcwxXmDsk6yacSGU=w300-h225-p" + ] + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Ik vond het opzich niet een sle hte museum, maar helaas waren er niet genoeg activiteiten voor een hele dag, maar aan de andere kant is dat ook niet zo erg. Ik ben daar naartoe gegaan met een school rijsje.", + "is_truncated": false, + "reviewer_name": "Archi Megapixel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVg8nYdmyKfzbmo2b6mUzJFQn8AteMm2lRLdBI7nA0wyozQQ-PU=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "8 jaar geleden", + "text_full": "Hele mooie atmosfeer en uitzicht. Er is een restaurant aanwezig. Prijzen zijn matig", + "is_truncated": false, + "reviewer_name": "Gregory Krasnov", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVqSAMGqbH_E6XQHnkFwrW1rd94XHVOeUrB_EYGt9yGYV8ACJS9PQ=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tWDtgtSSRg5ewdNlSKupDUjFA7KJdkctM7SBvjRUXKVXWCK-XJcXNzPvH-aMvtW5AOB2Q_Gmv2JDie9kdkL5EwfUd97nBrJgdqs5xItIQfue6Eiwp_O7YiuBn0FgSDVLdUs0Dw3g=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vWBI4sB1KJh3f9b76bXaZtPv7yfTuy7RgnY12DFckMxQzplY2QV1c2WCriM74iYW9PgQlV-XoDlvR5DDz5pieEx1VVcFYb64y71rKij697Ow-qemSCDk3LcvyzWVzr2J_5wlaL=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "Bewerkt: 2 jaar geleden", + "text_full": "Eye maakte het mogelijk om stil te staan met een filmavond, geoganiseerd door Sandra van Beek, met het overlijden van Kees Hin . Dit gebeurde ondanks de corona beperkingen op gepaste wijze.", + "is_truncated": false, + "reviewer_name": "Erik Pels", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUA_xzn2UbGenTRDxB2xrhr3n8fRCulbEmLvqpR_khAruXKnjR_rQ=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sfDtK05xF99_lMfrNRb0vl41NhTNaiAPxvnz6oKp27qZHuscV2Th358kx7Q7SBNCZZx7JV_TWt5dPq4Zh-x9NgAs8_luVL9fGH0sSjKeUYGBBv2xF5Ej_SN0U8UhceWeI61PlGBA=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vMomvSrl6x8GwxwfQKsMJY2cFrTkiDyCnn46-uqpsO7HHXoHimBXQd697aD-evgfJVbQAMmx0qB5hOl2dNh-44IhQjIeHhfKvSPSaODcGMM-IiqHZrJNt_dyFyYuUHlOzUK2A=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "4 jaar geleden", + "text_full": "Fantastisch gebouw, prachtig uitzicht. Vooral bezoeken!", + "is_truncated": false, + "reviewer_name": "Duco Mansvelder", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWZn5ikNQkW_0ttVB8xx6eiUBsV5gl2phJwfUwjdKDIiWnZSUT3=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38smcozPGuFSzxG7BR1CxvtVbb4I8Jf2uxKTTipgRmOrFOehvazRCRiJM5Uc_rCfQI-UddxnEw79xuoXPQ8-z1mkT5OhPD6E6vN6y0MhK_q_N_1Glxis9sJLMiln8cjXh5TaYGxVlQ=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tufHa5Sg0wicic4mx8nc7rWMoyD7X9Pp1x6HYLHPwWlfH8gBFUsYDWQrOwprjz0XloVrngGXdGCX8tJMmICvNrXXzGvH-ouEshLG9VX9kdlo1x-i-0HxOkfOesgfvoLxLKKYQW=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "Bewerkt: een jaar geleden", + "text_full": "Kwam eigenlijk nooit aan de overkant van Ams, maar zag van de andere kant achter het cs een vd mooiste gebouwen van Ams ontstaan, het eye filmtheater en de schoonheid van het gebouw zit ook aan de binnenkant. Echt wonderschoon, prachtig", + "is_truncated": false, + "reviewer_name": "Eric Bovenkerk", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJB7ovTZARRHPjCfoyi8sY91gaJjf4axDNOgZmyHWxx8HfE2w=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "4 jaar geleden", + "text_full": "Ik kom er bijna wekelijks (vaak op de maandag). Drink een theetje, kijk een filmpje. Mooi gebouw, relaxte sfeer en vriendelijke medewerkers.", + "is_truncated": false, + "reviewer_name": "Semih Turan", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW70JttH7dLjRHaSk5wx1k9Xbhf66xZx5bcotSVQxh7HPU9nRny=w36-h36-p-rp-mo-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sP-HuxNxyd6P6PC4W7JN4pz0vFFjhjpdqx7kyHDALcJu6GIBVKcDGV9AkjSxg4zyU6fYNK9DW4M0KQ7BpcOa_lMr8QBUpczgm3H84GSuzEG-hV2doaBW2w3rgRj6hpG71YqoXQ=w600-h450-p" + ] + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Prachtig gebouw natuurlijk, maar wat er te zien was deed me weinig. Zal wel aan mij liggen misschien, maar een ruimte met 40 beeldschermen met evenzoveel bewegende beelden is ietwat te veel van 't goede (?).", + "is_truncated": false, + "reviewer_name": "Appie Van Der Weij", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLyUMfLOTzpRFDlh6ZS5YnaSi9JwD0b34_py8QDKzBsZ7uiwA=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Mooie IDFA-documentaires gezien met een klas. De begeleiding vanuit het museum/IDFA was top. Leerlingen vonden het ook het museum zelf zeker de moeite waard.", + "is_truncated": false, + "reviewer_name": "shinto sanichar", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIz3oXwD73hRWkOP_GZ9k7JDA82e4arbK0Qg21n_nfJzVeNseNo=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "6 jaar geleden", + "text_full": "Geweldig gebouw. Superleuk om er ook wat te drinken en uit te rusten. Prachtig uitzicht.", + "is_truncated": false, + "reviewer_name": "Marcel Tettero", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW3XPaEKnbUBi286hJgSJDqHvfHeGesuf-iNhupks2RmXugdask0Q=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uNbTTU_X--dwcEJ7MQXR7beNmVm7MZxo6D2rtIRgVzFP5bg9qxHTnvb8x47vm-5T564yZCNSPDyonX3HnM0ebXUOUTbnenygKQmH0Ptq1FBWJM-G8ph_7Y4cjIHlt1bE_5Ky9x=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tPIAhFd6k74byEzRN4a6B6qeZR0HahfVt3jvUTvspwKnQz0takhIr5dbwZQf79M-_ShqsO-oylrijG5wTnxs-dOR04zOgNUBEvCk04nTpAVJ968fOwGOPxXtZCI88mcQisXD4=w300-h450-p" + ] + }, + { + "rating": 1, + "relative_time": "een jaar geleden", + "text_full": "De tijdelijke tentoonstelling was een vreselijke teleurstelling, ik weet niet of het aan mij ligt, maar ik kon in het donker niet zien waar ik liep en zelfs met de zaklamp van mijn mobiel kon ik geen richting bepalen, slecht bedacht op deze manier. Maak dan een vloerlichtje of zo!!! En de vaste tentoonstelling had ik in een kwartier wel gezien. Vrij prijzige ervaring op deze manier als je daar € 15. voor moet betalen.", + "is_truncated": false, + "reviewer_name": "Marjan", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWKBBtrL7mJkbwgGfPHuHQcY7NgwJQH3xbswQ3Lo7-Lx64iIG0B=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "9 maanden geleden", + "text_full": "Verrassende gerestaureerde Russische film gezien.\nNa afloop thee met uitzicht in het restaurant.\nAltijd fijne plek!", + "is_truncated": false, + "reviewer_name": "Sophia", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJOZ_LhVzfpP7uLhTA64Z_sf9mwSENGvZnvPrSnfuNMF1ufZg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "6 jaar geleden", + "text_full": "Makkelijk te bereiken, mooi museum. Vond zowel de tijdelijke als de vaste tentoonstelling interessant. Heerlijk ambiance!", + "is_truncated": false, + "reviewer_name": "Ayesha Leito", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocL0U_y_36IRaFGi011AIqmT9dsZHTXFTOuLo64iqNt2ix3XbA=w36-h36-p-rp-mo-ba2-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sEtQmLhoQFfqMN1fbN2kU3BGcTnkPXd12hwh_OfADZk3PE7DANVXn350dB8ivU3Cimp0f3ycfFNNKfjiRESGyvrs4-7eY0-T_GyLpZKNxY3Zpo6q6dkoOyOZ2jROGKD0_1L5DqXw=w600-h450-p" + ] + }, + { + "rating": 1, + "relative_time": "Bewerkt: een jaar geleden", + "text_full": "Wij als filmliefhebbers gingen met hoge verwachtingen naar Amsterdam, speciaal voor Eye. We hoopten op bijvoorbeeld toffe filmrelikwieën, kostuums, van alles over Nederlandse filmhistorie, dat soort dingen. We hadden ons beter moeten inlezen. Dat was er allemaal niet. En het was klein. Niet een beetje klein, maar heel hééééél klein. Een behoorlijke teleurstelling. 15 euro de man vragen voor één zaaltje waar wat oude projectoren in staan (deze 'permanente expo' heeft ongeveer de grootte van een klaslokaal, die op zich wel leuk was). Toen we die zaal gehad hadden, dachten we: benieuwd wat er in de andere zalen staat. Nou... er waren dus geen andere zalen. Dit was het. Er liep geen personeel rond om misschien nog dingen te vertellen over het 1 of ander. Dit zou misschien 5 euro mogen kosten (dan was het nog duur maar ja... 't is Amsterdam). Je kunt beter naar Beeld en Geluid gaan, daar leer je 100x meer over de geschiedenis van het bewegend beeld.\n\nVervolgens hebben we ongeveer een minuut in de tijdelijke expo gestaan, daar werden een paar vage 'artistieke' beelden vertoond van een wazige filmmaker waar geen touw aan vast te knopen was. Had niks met filmgeschiedenis te maken. Niet ons ding.\n\nDit museum kun je wat ons betreft beter 'Ai' noemen dan 'Eye'.\nMisschien is het leuk om er films te gaan kijken, dat kan er ook. Wij kwamen voor het museum maar dat kun je dit eigenlijk niet noemen. Noem het dan een filmhuis, waar toevallig in de kelder een paar projectors met wat tekst ernaast staan.\n\nIn het restaurant (vele malen groter dan de expo) werden we wel naar een tafel verwezen maar vervolgens niet bediend, dus zijn maar weg gegaan. Lekker in het Vondelpark gewandeld.", + "is_truncated": false, + "reviewer_name": "Erik", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXJlGRlCRkKKJBz40Xq006EOQKRNThA5J-fy5QoyAxvlL49fxe9=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Mooie bijzondere architectuur bergt een schat aan filmografie. Een waar paradijs voor de filmliefhebber en bioscoopganger.", + "is_truncated": false, + "reviewer_name": "Ron van der Gaarden", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWuIMIFsaKTod6LelJm-QltGlSbJRC9TK7nY19rzD4yzZGHszA=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tmH_p9nM-42eAjmI8JZYfjz5M9AXg-d-YDqd-jVs-fJ27N8nSo3YnSbtzRwzrGi6yBddMQB1J97ORSP9ZKq_j3Gy_1xY4Wn4EoIwHHq2UNS0VQTFdsWUNna82fbizJxbKwAuTV=w600-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "6 jaar geleden", + "text_full": "Mooie tentoonstellingen op prachtige plek. Fijn café-restaurant en aangename filmzalen.", + "is_truncated": false, + "reviewer_name": "Sigi Piep", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKyGW1QuwnmmCEgNSdm-9qVsccFKIKbO7NyyZOmkVTn0OqOZg=w36-h36-p-rp-mo-ba2-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t9oNzQQIXnfaWuLGJQ_f_dEgAuc4u-uUwO8dczeNaH_RdBfhce0yoYyB2Nd-UnwTgI3_5OAl9jm3zQR9Ai5R7JSAq0g_Vlq96VJ3jQK3VNdGKP5wnfiX1wcEBA90wWa6PuhXIhTA=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sY3xU9WhL9KgbzNELi0wSx0kqtWAw7S8_aLr5rZdiQZQaSXbXBmOuo_-dvbQ2w5lgRPwpVzg4ocYUUX7Z-_1RAnfGyMNJPAshgUAt5vRBZE9cumCPusP2BU4wvH6X0VNKgxv09Tw=w300-h450-p" + ] + }, + { + "rating": 1, + "relative_time": "11 maanden geleden", + "text_full": "Geen mogelijkheid om geld terug te krijgen bij annuleren tickets. Uit principe kan ik zo'n bioscoop niet steunen.", + "is_truncated": false, + "reviewer_name": "Bram Kroon", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLwAMmQ88-mqysleJzg_y-7FNlYdL0cIhreQuHBmAqKj5VL1iE3=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "6 jaar geleden", + "text_full": "Prachtig gebouw met uitzicht over het IJ, filmmuseum met bijzondere programmering.", + "is_truncated": false, + "reviewer_name": "Guido Schouw", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUKphsrjM8pe5buuqdLH1mkPr71qJVI0SwvrI_-Sqruh1pyCF4=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tw8DLXBjaWWrpwQ6h9NZMMNCHaEABrn88d65jI1bi0eTC5vCQ1IvjmMmGlyrG3ncqDYy_jVKPAOOHBSTu_B9gwAZkfSMrWpHbhkRWGgbNecLRbyXawFmlSzGZNbGtHB0tlBVS5=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sOBL_laCdymB9SFgljIm4I4vS6S8OgUEte3MZN5qUifNxcRHdGUEUN6pf6NzYsr4eDKN5fnDtotWtGFGrhpdDk-YEMfXzZxgKRsORsQMVbkFpV_bt-7KrcYXT77XrFeqY7zXkr4Q=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "5 maanden geleden", + "text_full": "Mooi gebouw met mooi uitzicht over het Ij", + "is_truncated": false, + "reviewer_name": "Monique en Ton Veerdonk", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVQRpDarYE_ETnEyJJZ654LdKrBN-oe1lFdXiWfgKeS5lIC67w=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "6 maanden geleden", + "text_full": "Mooi museum! Zeer de moeite waard! Aanrader!", + "is_truncated": false, + "reviewer_name": "Emile", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW1L8qPWnb88VX7yUehYuMgyA1N2VvyYVwaQaOe2Q1e1rzBrxt7Sg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Ik weet eigenlijk niet of ik precies van de film had genoten, maar de sfeer en bar-restaurant vond ik toch heerlijk en gezellig! Spijtig genoeg was het museum gesloten ongeveer één uur nadat ik het gebouw in ging, maar ik heb toch zoveel interessante en indrukwekkende filmapparatjes gezien en ik genoot er wel van, ook al had ik het druk om alles te zien voor het sluiten.", + "is_truncated": false, + "reviewer_name": "ASA ASA", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVqovHLs_evD2PLX1etKgp-udN1k9uiNUFC2Xjk3yUlWOdyCm0=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "2 jaar geleden", + "text_full": "Een flinke tegenvaller. Gelukkig met onze museumjaarkaart geen entree hoeven betalen. Heen gegaan met 2 meiden van 6 en 8 jaar. Binnen is vrij weinig te doen. Alleen beneden waren kleine activiteiten zoals een quiz met 6 vragen en via een green screen mee doen in je eigen 'filmpje'\nSpeurtocht voor de kinderen viel erg tegen, allemaal onduidelijk en toen we het gingen vragen bij de info, wist diegene het ook niet. Erg klantonvriendelijk, zij was een krant aan het lezen en wilde ons niet helpen.\nHet eerste museum want wij niemand zullen aanraden..", + "is_truncated": false, + "reviewer_name": "Dennis en Wendy", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIQJJvnF1n54ctLzkQ_d2ssrdJ5QHGNLOJeyLQWTV8ewUPlcA=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "10 maanden geleden", + "text_full": "De reguliere tentoonstelling stelt niet zo veel voor. Wisseltentoonstelling was mooi maar kan wel uitgebreider.", + "is_truncated": false, + "reviewer_name": "leo wolterman", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocL701ZPnemg20ug1vn-bumOjjnmoQCO8bQndoXSYB5w3Y6D_A=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "Bewerkt: een jaar geleden", + "text_full": "De films doe vertoont worden zijn geweldig! Het restaurant werd al minder, maar is nu echt slecht! De garnalen kroketten van Holtkamp, waren een aanfluiting! Volgens mij hebben ze één kroket in drieën gesneden, opnieuw gepaneerd en zèèr slecht afgebakken. Twee stukjes stokbrood en een onbestendige saus, die door degene die het bracht op tafel werd gekwakt, waardoor de saus omviel. Het enig wat er goed uit zag was het schijfje citroen. het zakje met bestek werd er omgekeerd naast gegooid! Schandelijk! Het personeel heeft meer met elkaar op dan met hun gasten, één grote aanfluiting, jullie moeten je ogen uit jullie kop schamen!", + "is_truncated": false, + "reviewer_name": "Betty Van Tol", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVzCTF514lp2caxJYvrRtxR73BgOvyJYzazC-esF9aUYS4PiWa2=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "2 jaar geleden", + "text_full": "Review gaat over cinema zaal 1. Stoelen zitten heerlijk, beter dan de gemiddelde bioscoop. Hier een film op 70mm gekeken. De kwaliteit is goed maar de digitale bioscooptechnieken bij de andere uitbaters is voor de gemiddelde kijker niet merkbaar. Het geluid in de zaal stond zo hard dat we overprikkeld de zaal uitgingen. Ik was hier niet de enige in.\n\nAl met al leuk om een keer gezien te hebben maar ik zal niet snel nog een film hier kijken.", + "is_truncated": false, + "reviewer_name": "Sebastian", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKQ-9o4qsWv1F8AeM7B49wDFIzxDxwmmfdy2hMWCCMzyxpWjw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "6 jaar geleden", + "text_full": "Prachtig museum filmhuis en restaurant aan het ij in Amsterdam", + "is_truncated": false, + "reviewer_name": "JWL", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXz2bG-88h305RczFqLLeOdWDmH0Dcuxdnyate3hIiJ64QvZw19=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38spuP23Mbamn83yrgAk-yBQ6bWTD3vf4KvkQsyDpvrJ7cTjYjtD3dW3bpSznMSxFpECwJ-7bKChykWmLNiq-bvj18W71-uSIj4hpgq5WiMRe0D5y9xF0pziRQKY_Qb5VDrePEY4=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uRhLi-Nqc__QuYqXIXw6DOqqrQXjYGEnV3g3_LdfLJ9Mj4xk1epZkZXOK_83oDr1CYJp-B_h5yZACLsJcUk9ywGalHE912vs_fSJ6gRpCaNGJvpfmlPmDGaIwrpIp7B64t00M=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uS7MWh_ZI-BXSyrwm9WjHyqtX2PJ3pt8LZOcghjfb3tVwPX8nvMU0YZz7DJJwnBjMEdCl8cyq99GFevOf0SGirQpKW_IJjB-65msOggcWmYXTJlDGAqFjQieacn2ry9rnBuJcZ=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38s6a2BhfvZr7bqFfELZvbvak5kWPe7oXEeDMF6S3_N5ArkcggmnWW3TNJNzeGW2gGO89dNdTm5prBLpPkqmNMclejvyq5FFY2YfXXJ5qoZT5qe7DhrxTkxhmE0g39RYJI5LNnNL=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "3 jaar geleden", + "text_full": "Vergeet niet iets te gaan drinken en/of eten :-)", + "is_truncated": false, + "reviewer_name": "Stefan Meskens", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWV8a1NqpleHPuVn4WjZbRz_3ostpdQJQrPB9csX92TdpQNRw9-iA=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38ves4cxyUwYDxyZVDNQKFLMmlWY1HiFQ4nNvRKzTLHRGFaI4kZ_GAuISTYF1Uabvfl2khq_Dwqwng8E73wT8iDRXuBJYnRN3FxoWDWQmaQpYtc0lM_ATGGX2Uu-ANbvL2A3se2S=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38sFbKccxvo08rCFk0tC391M8I7PTOudLNSV-4KvPEWro6t1xy_ntbOdx1QoaGVwAdCcZ9LqCV_tLl6sNkp0_-XEM7umZm0LwmwxZX7vsvavZLUOVOFH4mHc-XrP5UPf-sDvJ64H=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uyiIVj2bVl15pyJjO6b-RnUnT79ylNNRzApDp8j3_KP0ghVtmLXko9vtdQimm1sI0rgnwi2Rx3hcGUimqqIpHypZgdUEZNtcy2nwn9eK9GoMZDoQUfU66Rh5b1ImTVAGt2IDvr=w300-h225-p" + ] + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Genoten van koffie op het prachtige terras aan het IJ. Wel apart dat er voldoende personeel binnen was, die niet druk waren, dat je toch naar binnen moest om te bestellen.", + "is_truncated": false, + "reviewer_name": "Bocaj Nesnaj", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXTDsJq84n5C1A4JCJCGz23rnAuFJ-wAAMtuYve6WepOVwEZrS3Lw=w36-h36-p-rp-mo-ba8-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Een mooie bioscoop met oudere en afwijkende films. Wij vonden de film die we zagen prima, het is dus maar hoe je het bekijkt. Daarnaast een prima horeca, fijn voor na de film. Wij komen er zeker terug!", + "is_truncated": false, + "reviewer_name": "Maarten Vermeulen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJ2a63Wn7ng5zHYE9mZXf3eOzN-MC6q2AqNuGfUIWtM1Nby1g=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "3 jaar geleden", + "text_full": "Heel interessant museum met verschillende exposities over film. Dit museum zet je echt aan het denken.", + "is_truncated": false, + "reviewer_name": "Rutger de Groot", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjX2IH7va7JndyzwWQqCe_8R4B8bIGJed3TaBQDMCP2gw8VqHrJ6=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vvllIkFOh-j1HTlnJF9k6wlTHPond23jsxM7kRGE5_ZfXvy1xTj8n_OMDE4suZE81OAju6n8R6jd9SOS8L0hHOdqXU5iU6rKM5q-m0prRCKFtb1dEYJ4Hm7olePbxIVHZu2hc=w600-h450-p" + ] + }, + { + "rating": 3, + "relative_time": "4 jaar geleden", + "text_full": "Mooi gebouw en leuk zitten, maar meer een bioscoop dan een museum.", + "is_truncated": false, + "reviewer_name": "Michiel de Graaff", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjU7i871GT5LseMelGxLGLOvC4qXB3z3_-6L8icJRA8d9b9LdJN_=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38t0Yk7PEGOcsMly6XhSLSqRAD__ItMrOljzEpYI2xzxyIFt1C4i9alLWKEz4RIaV4HziPJfJ8SFmBTujx8RtVGZZmIv_oi1iuEOgqBwaw6NrH5c1Y62r7VkiMsHeyO-MhSw3q_0nw=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tWIby3ZZ9WNKFdOUFOCQlAbG0vxW3elds1m2Et4JnUE25rTeYYjERxXw7WFY4VucTZGqqVzypr_WO0C3CfK8niZurM52YbtJ_7RTr8G3nSqdlC8TDBwUdWjeYqHfk4eyraWiJi=w300-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "Bewerkt: een jaar geleden", + "text_full": "Het gebouw is wat klinisch, met een wat te hoog inzettende bar die ook chique restaurant ambities heeft, met matige akoestiek.\n\nMaar de ligging vlakbij Centraal Station is ideaal en waar het om gaat, de filmzalen, zijn uitstekend, zeker zaal 1, waar bijvoorbeeld 70mm films vertoond kunnen worden. En belangrijkste van alles: ze vertonen hier films die je nergens anders in Nederland komt zien.", + "is_truncated": false, + "reviewer_name": "Martijn ter Haar", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWfJn6_IzCDNY5xbpdB8ywyb2l6jhP0_xiyZN7MZhel9b6tyT5s=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Heerlijke plek om rustig koffie te drinken, lekker te lunchen en uit te kijken over het IJ!", + "is_truncated": false, + "reviewer_name": "Astrid Bulsink", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWDIsyRg52wAO3W-MYqzQM62bAoIzqmraC7cvIZmxMxJBtBXu5z=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vBTQdA7fLf7AuwYveTIgzRdmbFDXX2au021JzKxBAb99xoq6cp1UffA15nXzNkqyylXVbFoIfgKjVsYXWZ9q9msVIQKogJn55Z0aYFi5wO0qX_-JSo74iVpVp47NVSfyeCXO9xvw=w600-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "7 maanden geleden", + "text_full": "De historie van film wordt duidelijk in beeld gebracht I n de Permanente tentoonstelling.", + "is_truncated": false, + "reviewer_name": "Gertjan Hartog", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLbKe07xRrLS4-suU41Ed0OAY0xkGC2pPSBTMsfVW87KuyYPA=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "Bewerkt: 3 jaar geleden", + "text_full": "Gebouw & locatie geweldig!\nDacht dat je ook films kon bezoeken in mooie zalen, maar je moet apart kaartjes kopen(?)\nGeen info gekregen bij entree van zwijgzame dame.\nExpositie bezocht: zowel partner als ik begrepen quasi-intellectuele aanpak hiervan niet. Man die expositiekaartje afscheurde, vertelde al fronsend: 'Het is heel bijzonder', daar kun je natuurlijk alle kanten mee op.\nAls je filmzalen wegdenkt vindt ik aanbod zeer summier. Restaurantuitzicht fantastisch, maar daar kom je toch niet voor?\nHoogtepunt: oude filmbeelden ambachten e.d.: indrukwekkend. Helaas werden er tussendoor chronologisch Nederlandse brieven in het Engels voorgelezen. Deze brieven hadden i.p. niets te maken met niet-chronologische beelden. Hier zal wel hele diepe gedachte achter zitten, maar wij konden dit niet 'vatten'. Had me uitputtend voorbereid en van te voren allerlei filmpjes bekeken, maar was toch best teleurgesteld. Oude info + oude apparaten in kelder wel heel mooi & informatief.\nOok jammer: mooiste uitzicht is bovenin, maar...alleen voor 'ingewijden'.", + "is_truncated": false, + "reviewer_name": "E de Lange", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV5AHhIdf6s3krw0NAHTifV5DAk9KSjhelzQV4v8hFOq_dQ8z_3=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "We hadden de film Oppenheimer bezocht, aan de film lag het niet, wel aan het geluid. Dat stond echt veel te hard. Ook hebben ze bij het Eye geen last van dure gas prijzen. Je kan hier makkelijk in een zwembroek rondlopen.", + "is_truncated": false, + "reviewer_name": "Jeroen Meester", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocL4b4mnSZP-pAPhDagkn4bgt42cz9BpvBhp7YWThUBhzv6cuA=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "3 jaar geleden", + "text_full": "Fijne plek met goed filmprogramma, mooie tentoonstellingen en een café-restaurant met uitzicht.", + "is_truncated": false, + "reviewer_name": "B A R", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXEobqpHipnRjaITFoD6n_GalA3nrZq_iwPXLOtQ-lo9rm-ogmi4w=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tumB_Tr_xDi9qIFbZxZ_plDBcC0fidGejAvyCjg6Kyj_2NtOP2hNkR44MG3-l9q7xkqAWkcTOyYvnJxQh8QIk7pBJQ22S8W5NvLb4JpV6WnehSBWzZzSYyi9rCf6eU5n9nV89V=w600-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Waar ligt dit ...in de stad of buiten de stad.\nWij waren met de fiets naar Noord Amsterdam gegaan en gestopt bij eye om iets te drinken.\nZeer modern en hip gebouw waar ook wat dilmgechiedenis te beleven valt zonder echt naar de exposities te gaan.\nHeel gezellige drank/eetgelegenheid.\nCorrecte kaart en vriendelijk personeel.\nAanrader.", + "is_truncated": false, + "reviewer_name": "sven vanbrabant", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocK8UFsFgEc7Zpg_BcvamLSI6OsK8-wOrQPUx7LipajL_-mDZw=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "11 maanden geleden", + "text_full": "Paris Texas gekeken op deze bijzondere lokatie. Een topfilm in de Amsterdamse film tempel, wat wil je nog meer.", + "is_truncated": false, + "reviewer_name": "Jan Ikas", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVnTHHka-V7lXEnKwJNPcOJZwuZqQMzn9Q_kNOCTNgRLjIskHs9qA=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "3 jaar geleden", + "text_full": "De vaste tentoonstelling is klein en valt tegen. Er is aandacht voor de hele oude geschiedenis van de film maar totaal niet voor de recentere ontwikkelingen. Misschien krijg je meer te horen met een rondleiding of als je er komt als school, maar wij wilden zelf op ontdekking uit.\n\nIn vergelijking met de pods van het Nationaal Militair Museum zijn ze in Eye voorspelbaar en ik wil haast zeggen fantasieloos. De koptelefoons doen ook gigantisch pijn aan je oorschelpen als je iets langer wilt bekijken, wat voelt alsof je gepusht wordt de pod snel te verlaten. Er zijn er ook maar 4 met elk hetzelfde aanbod. Biedt dan niet de mogelijkheid om een film te bekijken in de pod aan als dat eigenlijk niet past voor de doorstroom.\n\nDeel van de vaste tentoonstelling zijn een aantal installaties van prijswinnende jonge filmmakers. Die installaties zijn op zich interessant, hoewel je bij sommige kleinere schermen erg afgeleid wordt door een megascherm. De installaties zijn ook gericht op jong publiek dat gezond van lijf en leden is. Het is niet comfortabel langere tijd ergens te zitten.\n\nAls je komt met de verwachting dat je dingen kunt zien van Nederlandse bodem en de ontwikkeling van de Nederlandse film of Nederlandse regisseurs die het goed doen in het buitenland, dan kom je bedrogen uit.\n\nVoor iedere film buiten de vaste tentoonstelling betaal je extra. Aan het eind van ons bezoek kwamen we tot de conclusie dat we niks geleerd hebben van dit museum. Hoewel de installaties een paar momenten van goed gesprek hebben geïnspireerd, moesten we het toch vooral hebben van elkaars gezelschap.\n\nBespaar jezelf het geld en ga naar een goede filmhuis film of de reguliere bioscoop. Drink iets achteraf samen en praat echt met elkaar. Daar heb je meer aan.", + "is_truncated": false, + "reviewer_name": "DutchJoan", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWxrqDD4p70_sPFzrZhO2PrP_pyuEYKlLYRxGHQIu83h_vvNZSY=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Film faciliteiten waren prima. Eten lekker. Echter was het personeel niet goed op de hoogte van het menu en hebben we tot 4x toe items aan onze tafel gehad die we niet hadden besteld. Ook stond het eea hiervan uiteindelijk op onze rekening.", + "is_truncated": false, + "reviewer_name": "Joost Zuidam", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW_y9lC2dFNlens9S64sh1waWpWJ57l5rpMExeTMvkSww2IuvRRRg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "2 jaar geleden", + "text_full": "Met hoge verwachtingen hierheen gegaan en met grote teleurstelling weer naar buiten. Bij een filmmuseum fantaseer ik over attributen uit bekende films, decorstukken uit legendarische scènes en misschien een restaurant dat met een knipoog in thema is uitgevoerd. Wat krijg ik: een serie posters, een kelder met oude projectoren en 1 tentoonstelling die je maar net moet aanspreken. Ja, er zijn films te zien en extra tentoonstellingen, maar daar moet je extra voor betalen. Over dat restaurant gesproken, nadat je naar een tafeltje bent gedirigeerd, laat de bediening je aan je lot over. Na een kwartier zelf maar wat gaan halen bij de bar, terwijl het personeel vooral druk is met... ja wat eigenlijk. Geen oscar voor dit museum.", + "is_truncated": false, + "reviewer_name": "Qriztov Dutch", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJJVQKNbMkAbXhXoxxBlYQx0PN6NJFnOwAmxNTQHwUlRIuwMA=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "2 jaar geleden", + "text_full": "Hierbij nog een review over Cinema zaal 1. De stoelen zitten idd heerlijk. Maar ook nu stond het geluid zo hard dat het heel storend was en het de concentratie verstoorde. Je zou de film met gehoorbescherming moeten gaan kijken en dat kan toch niet de bedoeling zijn.", + "is_truncated": false, + "reviewer_name": "Alice Schippers", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXC9zexd8e5imojvv3y2SatYTF4g6SLCDixOtlqfq_pRDVKvgEbPg=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "2 jaar geleden", + "text_full": "Afgang van een bioscoop, geen prettige stoelen, geen fatsoenlijke bioscoop versnaperingen. We hebben 2 calamiteiten gehad in onze film en ze konden er totaal niet mee omgaan. Ze spoelen de film ook niet voor je terug. Daarnaast miste er ook een groot stuk audio. Lekker naar de Pathé gaan dus...", + "is_truncated": false, + "reviewer_name": "Maurits Diepeveen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXjCO22spbhsGXRXDPjHewce3xkjF1HsS0kqJ1V_G7f0qGUTPBy=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "2 jaar geleden", + "text_full": "Sorry maar ik vind er echt niks aan.. Zonde van je geld.. 15 euro pp..", + "is_truncated": false, + "reviewer_name": "yvonne", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWDs3cYlouCv1In2KmnP-7V7HnxkiXb_sn2XG-xkdB9LpOlEm5z=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38swaDawbnmxkUauQx0oK85_-n13JsfRGc-b4NH5MGDjnIStY6TD3UHfac8zN3bFyDfAcka2uVOnRIDmmdMOOBej92gdVenYId-L4K-A8vMc-TeSG3zrH2nwEY6gBwHTlIAZRZ_H=w600-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Leuke museum voor een middagje. We hebben op straat geparkeerd wat €3 per uur was. Ticket was ongeveer €15, flipbook zelf was iets van €6. Was bang dat flipbook machine stuk was, dus heb gebeld naar museum en die was vriendelijk aan de lijn en vertelde dat flipbook maken die dag kon. Voor de rest heb ik enorm genoten van de greenscreen 😂", + "is_truncated": false, + "reviewer_name": "Sultana", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWFbcr5ml6iiWLS6RCK_x-lM_X6QPCOCdSaZtYJv5eowiTfmoc=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38uqjZ8zHvpVharZyNOqJSVN1ZdfZjTDo_BLSud4hwGYTcrAAr0TdfSGa2_HWXcrzPPM9NnEMBuxZdNFABMbasKYg-BepLHNfpSJLFg5H6m_UsXJrTJ-JO-Zo676UIcTINuUR5rGIQ=w600-h450-p" + ] + }, + { + "rating": 2, + "relative_time": "2 jaar geleden", + "text_full": "Luxe museum, maar wat een deprimerende expo over Werner Herzhog. Echt een geweldige afrader met kinderen wat ons betreft. Toch nog twee sterren voor het standaard museum beneden waar we heerlijk even wat oude filmfragmenten hebben gekeken.", + "is_truncated": false, + "reviewer_name": "Arno Lanjouw", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXWgzwuz0X3WlUtG2tX-Ifca7LVbGkbAoLbwlKrbU6VBujhBzUbYw=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "3 jaar geleden", + "text_full": "Het is een imposant gebouw, maar het merendeel van de ruimte is gereserveerd voor extra betaalde voorstellingen en grote vrijwel lege ruimtes voor een expositie van een IMHO zwaar overschatte 'kunstenaar'. Het museum gedeelte is erg klein en in een half uurtje wel gezien. Voor 12,5 euro zeker niet aan te raden om te bezoeken.", + "is_truncated": false, + "reviewer_name": "Michel Keijzers", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVOP6o3W-b121P9Fcy4HWYLbY-fqZXQjGvvkgyDPo96eYhyS0Tq=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "Bewerkt: 2 jaar geleden", + "text_full": "Kraanwater op de rekening vind ik echt schandalig !!!!\nZeker omdat we wijn dronken\n\nHoofd en voor gerechten redelijk tot goed\n\nFrietjes van zuyd waren niet lekker/ gefrituurd.\n\nDessert waren lekker", + "is_truncated": false, + "reviewer_name": "Den", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIMMktwK6byWYQStEhx08kin-XG9GmREz16Ul1gZox80Ph16Q=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "6 jaar geleden", + "text_full": "Het museum was kleiner dan gedacht, mijn vriendin en ik waren er in minder dan een uur doorheen. En we namen toch de tijd om alles te bekijken. De meeste ruimte wordt ingenomen door de bioscoopzalen. We hebben een Museumkaart, maar moesten nog bijbetalen om de expositie te zien. Ook de films zijn alleen met bijbetaling te zien. Dan ben je nog meer kwijt dan een film in een reguliere bioscoop.\nHet bijbehorende restaurant was helemaal treurig. Na tien minuten zaten we nog te wachten om te kunnen bestellen. Genoeg personeel die heen en weer aan het lopen waren, maar daar bleef het bij. Heen en weer lopen. Ook zwaaien en roepen had geen zin. We zagen bediening met borden voedsel lopen, en een aantal keren naar verkeerde tafels gaan. Ze wisten gewoon niet bij wie de bestelling hoorde. En zo druk was het niet.\nUiteindelijk zijn we opgestaan en weggelopen. Mijn vriendin is zelf catering manager bij een groot bedrijf. Zulk personeel had ik er allang uitgegooid, zei ze.", + "is_truncated": false, + "reviewer_name": "Poppy van Diest", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocINrCBswVKK0mw5Mv-Xt2C2kUXVbEXljOBAecO4yG7GhpetRIMp=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "2 jaar geleden", + "text_full": "Flut voor kinderen. Maar heel weinig wat leuk is voor ze, alleen een speurtocht. Geen aanrader met kids dus. Restaurant bizar duur, 19 euro voor 2 cappuccino, 1 appelsap, 2 bonbons en een klein cakeje.", + "is_truncated": false, + "reviewer_name": "Ester Wiemer-Truijens", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVzksxEs0qfPiL6iWUiHcBPhlBXsFqy7KKDcGwYjL__c1M4JOEPgg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Architectonisch natuurlijk zeer opvallend. Prachtige ligging met ook van binnen uit geweldig uitzicht De eerste keer ze de indeling en lat out van het museum nogal verwarrend overkomen. Maar de vaste en tijdelijke tentoonstellingen zijn meer dan de moeite waard.\nZoals in bijna ieder museum is ook hier de grootste ruimte gereserveerd voor de voedselvoorziening. Met gepeperde prijzen.", + "is_truncated": false, + "reviewer_name": "Folkert van der Kooi", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJj7ZkOkNHGZ5KFobPuX2L6tLWEJq9AyAXU79xjh6D_lqiefw=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Magnifique uitzicht, goede ideeën komen je vanzelf aanwaaien. Fijn en vriendelijk personeel", + "is_truncated": false, + "reviewer_name": "Marc B.", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXzC31k7QaOkF69Tw7EbEAP_HXP-nl2yoCIVrvs5gPhb0-MEovU=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tLWgz2-1RK2vYjZhaAosscaJ7hXi1RDB-OKcNqQipS3KaCvCyP0TwVo5LMhuePVM3aaHP_spZ5v39gdFYV34H1haNVUNvoF4cuAiJKKlLRTjmpXrdQAMvfEa3SlOSewgNdStcn=w600-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "5 jaar geleden", + "text_full": "Heerlijk gegeten, leuke bediening, prachtig uitzicht!", + "is_truncated": false, + "reviewer_name": "Carolien Hamel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocL0MyFBbKWlxZIq-RNS1gSqV0uU2IHa2wffe26sxhnjcFonpA=w36-h36-p-rp-mo-ba3-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38v4DvkZjZSXAXGbVeubLIrxEiw54hheZsiRHmCh1r6ihujXXSdyYhfJZqLLWjpvmTx1ApSozMG2iJBd8lWYJHDEDbVwVXLixWiobrvUGzcZkWi32_1oZraRRx47MN2jFaBxbJ2Z=w600-h450-p" + ] + }, + { + "rating": 4, + "relative_time": "een maand geleden", + "text_full": "Altijd fijn hier.", + "is_truncated": false, + "reviewer_name": "Lonneke B.", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKm8wvPOF-lpl6KEIvGhXMC0s7HiYyGNR0i0hJOI1ri0ORHOQ=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Prima, ook voor minder validen elk niveau goed te bereiken. In het restaurant/ brasserie voldoende personeel. Bij de bittergarnituur geen extra zuurtje of olijf, qua prijs kwaliteit zou dat wel wenselijk kunnen zijn.", + "is_truncated": false, + "reviewer_name": "Carola Keuker", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKv6nS_5e9f_RgPYM0hRkzNiUJPRBSMdEUwYc4gquO7hIPfcQ=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "6 jaar geleden", + "text_full": "Mooie plek , maar buiten werd niet bediend 😔het weer zou niet voldoende zijn ,maar de zon scheen wel. Beetje jammer er liep voldoende personeel in de ronde zeker 15 man en het was niet super druk, maar waren gewoon teveel met andere dingen en zichzelf bezig. Uiteindelijk een top kelner gehad die het wel de moeite waard vond om buiten te serveren en alsnog even heerlijk genoten van de zon op het terras.", + "is_truncated": false, + "reviewer_name": "Natascha Gajadhar", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJ1OtTQLqcsoupBUXI7XzwPssImvH_R2Tyv8J9wFO-Ex3NZig=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Unieke bios met museum op een fantastische locatie.", + "is_truncated": false, + "reviewer_name": "Monique Gielis", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWKIRtu6Yg84RaDTyD7M6iPoLbn6PGMGQjDjWqU0Nom6cesGmw=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38s_vKRLN_ceSTJUBJvmihq4uN9TIfLcQNv4WA1whgz7srPS1O2_zFVHt_lf0d2cADkbfg-TWjgAYMhmPnfBm0WtPh2-Sva4VZiN1vntna3mGj_DRbQOmIfXPbeOf1ehAllMR2dctQ=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38s-5sHXvCnKmRNIAY6f1suaof2FfEt5GkI1Ep902KZEId03ikzrykK8N71BdkNClzS8zbd2Q5VWV8bpeFNZQVnpIfRBjMDX3DgdtgLn_wj_dbC70Gyf8fW9UMsd8Se-6tdkvoA=w300-h450-p" + ] + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Interessant museum dat de kunst van filmen en hiermee provoceren, uitdagen, prikkelen, aan het denken en voelen zetten goed naar voren brengt.\nHet gebouw op zich is architectonisch heel interessant, een mooi spel van licht en geometrische vormen.\nDe locatie is fantastisch, aan het water..\nRuim opgezet en met een mooi cafe binnen en een leuk terras buiten met uitzicht naar de achterkant van het Amsterdam CS en naast de 'A'dam Look out'!\nGa je dus daarnaartoe, neem maar dan de tijd om zowel van een tentoonstelling te genieten, als van de locatie.", + "is_truncated": false, + "reviewer_name": "Vicki Kouri", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWlnKYxkaXJutNS-gVm82tRNK_3S8cqcJ4_ba8yHeuBp4rhBGXm=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "8 jaar geleden", + "text_full": "Voor liefhebbers van design: kom hier eens kijken. Bijzonder architectuur icm een prachtig uitzicht over het IJ. Let op de deurklinkjes van oa de toiletten en kijk eens naar de prachtige lampen.\n\nPersoonlijk vind ik de sfeer hier niet zo prettig: dat heeft met de acoustiek te maken. Het geluid blijft nogal weerkaatsen in de enorme ruimte. Ook vind ik dat de bediening het overzicht snel kwijt is, misschien door de opstelling van de tafels.\n\nOp mooie, zomerse dagen is het aan te raden te reserveren als je wilt lunchen. Wil je vegetarisch of vegan eten, dan zou ik dat ruim van tevoren en zeer duidelijk aangeven, want het is hier niet vanzelfsprekend. Een gemiste kans voor deze hippe plek.", + "is_truncated": false, + "reviewer_name": "Claudia van Eck", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWLikxfk3fcL37dYwp47T8KUinn2bl_pg0kyRmLPA7tElmGysjm=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Mooie film gezien in goede filmzaal, geluid, beeld en stoelen alles heel goed. Daarvoor lunch in restaurant, aardige bediening, lekker eten en een prachtig uitzicht.", + "is_truncated": false, + "reviewer_name": "Frank Vogel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWv29pFqJjKIFQGX39DisPOLnxe8q1gh1xjeksnuGrd8f-7jRjB=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "8 jaar geleden", + "text_full": "Ik had geen idee wat ik hier van kon verwachten maar het was zo ongeveer het meest teleurstellende museum dat ik ooit bezocht heb. Verschillende zalen met slechte kwaliteit projecties die ook totaal niet mooi, interessant of anderszins de moeite waard waren. Gelukkig hadden we toegang met onze museumjaarkaart want de €10,- toegangsprijs is het in mijn beleving absoluut in waard en ik zou er zelfs nog geen €2,- voor over hebben gehad.", + "is_truncated": false, + "reviewer_name": "Frans van de Kamp", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVc6LEZNvPiB1jdCEIX63LhggPJ94UcTky_GAbaPo8SQnQpe-Qo=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "7 jaar geleden", + "text_full": "Het Museumcafé is superleuk en op een mooie plek.\n\nHet museum an sich is echt wel mooi maar gewoon niet zo mijn ding. Je moet wel echt een voorliefde hebben voor alternatieve arthouse films, zo niet dan denk ik niet dat je veel in dit museum te zoeken hebt...\n\nToch vier sterren omdat het voor de mensen die dit wel leuk vinden een topmuseum is! (en er zijn ook leuke aanvullingen voor kinderen)", + "is_truncated": false, + "reviewer_name": "Stefan Boschma", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXKrzLNriyWBt039--rNanRo45PfTh1iL6l1JWekoGqInOIrLc=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Hoe je het museum vindt hangt waarschijnlijk erg van de tentoonstelling af. Die waar ik ben geweest vond ik altijd prima en groot genoeg (kon ik in 2 uur niet helemaal bekijken). Vaste collectie heb ik nooit bekeken. Het restaurant/café is goed, maar voor een plek met het beste uitzicht over het IJ (direct bij het raam) moet je kennelijk wel reserveren, want toen ik er was waren die allemaal al gereserveerd.", + "is_truncated": false, + "reviewer_name": "Agnes van Belle", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVqIRcYfW5aDVt7NJo7xkl7Z2zIZN4ui-XKGObV7I6DwkYbFPvG=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "De tijdelijke tentoonstelling (cosmic realism), hoewel met momenten gruwelijk, was zeer interessant, maar de vaste tentoonstelling was wel teleurstellend klein. Voor 15 euro verwachtte ik toch wel wat meer.", + "is_truncated": false, + "reviewer_name": "Erik S.", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjU3UDTEESicaI6jef-B5ICuf4LWgOhh27v0I8pAu4SK0OjFR7O2=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "8 maanden geleden", + "text_full": "Mooie bioscoop met interessante en niet alledaagse films en documentaires\nMet het mooiste uitzicht over het IJ", + "is_truncated": false, + "reviewer_name": "Pieter Veelders", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUPEXsSnbuN1Zs9y1r3IUZbxjqD4_WlTZvvSVjWa4Q9qoDgIgQnyQ=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "3 jaar geleden", + "text_full": "De expositie (Guido van der Werve) was niet ons ding, en de routeaanduiding had ook best beter gekund, maar het gebouw, de route ernaartoe, het warme welkom met de koffie, het adembenemende uitzicht, de oceaan van rust in een constante hectiek, èn de menukaart zijn top!", + "is_truncated": false, + "reviewer_name": "C. Jansen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIqwb7wc_XDlLpipt1PpOLXAaWMZ7j4H22LIGatFZvVXnmZEA=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Heb hier in het verleden vaak goede en minder goede films gekeken met m'n Cineville-pas.\nLaatst eens met aanhang een bezoek gebracht aan het deel van het pand dat als museum dient. De vaste collectie is een aanrader, gezien de technische snufjes. Leuk ook voor kinderen (6+).", + "is_truncated": false, + "reviewer_name": "Vikaash Mahabir", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXaEwyqnkil6EbCSgsPikh6qjXMELsbDWFo9W37CWHfSZzf58fP=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "6 jaar geleden", + "text_full": "Het eten, de sfeer, het uitzicht en de bediening zijn super. Ik kom niet voor de museum maar voor het restaurant en bioscoop. Een absolute aanrader. Ik lees wat negatieve reviews over de museum. Dat zou kunnen ik heb me daar niet in verdiept. Maar restaurant en uitzicht zijn super. Bioscoopzalen zijn prima! Ga een keer lans zou ik zeggen.", + "is_truncated": false, + "reviewer_name": "Soul of a fallen Angel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVoQ3uxKafwcbYhKcZ6CMTXQVc7LzNPk_ykBggHVTTwjbBSDFU00Q=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "6 maanden geleden", + "text_full": "Grandioos restaurant met brede trappen en een mooi uitzicht.", + "is_truncated": false, + "reviewer_name": "Ype Koldijk", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWtOQyDzJB78nA9vFRrvQecu-X9yzz3AhUcJaKhjqSS83E6OrTR=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Een must go voor de film liefhebbers.\nHet restaurant is er prima en een van mijn favoriete bioscoop. Als je koffie besteld neem er dan zeker een Madeleine bij. Best ever!", + "is_truncated": false, + "reviewer_name": "Dennis Dz", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWb8rB43BmgroBteG81tKijfaVnLWvd1V_o_PQgUWXSHxmUII7p=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "9 maanden geleden", + "text_full": "Nee, nee, vertel ik niet. ZELF naar toegaan en in je hart sluiten.\nWord jij niet betoverd, blijf dan a.u.b. thuis en schaf GERANIUM aan.", + "is_truncated": false, + "reviewer_name": "Peter Mosseveld", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUqVCaeFs5Go_jqT9pcYHKdzJhaulnVxGGbiHS_Pi1WeKUYVgzS=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "5 jaar geleden", + "text_full": "Prachtig gebouw waar het museum in zit, reis ernaartoe met de boot vonden de kinderen nog het leukst. Wel mooie tentoonstelling over kinderspelen. Er staat geschikt voor kinderen bij het museum op de site maar afgezien van de tentoonstelling verder helaas minder leuk dan verwacht en gehoord. Daarom met de museumjaarkaart verder afgereisd naar Nemo en daar de rest van de dag doorgebracht.", + "is_truncated": false, + "reviewer_name": "Josien", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLn0Cbh1wvc1vW2zpZXkyTCOExenPH-yQXHzzshd_Mvqm1GyQ=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Prachtig gebouw met aardig personeel, restaurant heeft en prettige, snelle bediening en een super uitzicht op het IJ.", + "is_truncated": false, + "reviewer_name": "Bert Hengefeld", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUeCVitG0G9rpzW3cCrRohj2Dyc7DGlsHKuIaXqozGhoEEBwJvy=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Indrukwekkende exposities naast bijzondere films.", + "is_truncated": false, + "reviewer_name": "Nien (Nien)", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW0OCQqN23vKXkAmNDYE3sUe9AlUJYG2OiFFa_Bm7VLTmTbuEl7xg=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tcCDCS6YBNxW_N0yd7OX_YYuAYBV3fh-_EX5dqZXc7uzu91rLzFUzuIKPsjEoCQScpC6-RHRmc3E4YNRb-27Xr8piNm6WitSWvqa2auSFtuu-LVsVk0Z39S1YOIAEs9U7a-WMZpQ=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vwy8wQoWVOdgndFDqOuz6gYXLYHBPUzLkNHxisEbhKwHmy6x9-i_zt56h4U7IbVp8bI81ky_Gq-_5TJxNmV25illXKsxp43XhQoKW0DEwxLKZrD7wLisrNaOV3ErKYAa0RsAM=w300-h450-p" + ] + }, + { + "rating": 2, + "relative_time": "2 jaar geleden", + "text_full": "Veel van de menu kaart was op. Moesten ook opeens betalen omdat bediening naar huis wilde. Was nog ruim voor tien uur. Nog één kopje koffie.... nee dat kon niet. Niet snel weer daar naar toe.", + "is_truncated": false, + "reviewer_name": "Robert Van Niedek", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocL7eLpOiY3_gdNlCYRFB5ewi0oKPx0ogl5NxhCxeLKPOaJuwQ=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "7 jaar geleden", + "text_full": "Het begint ermee dat de locatie in Amsterdam top is. Het gebouw is spannend. De voorzieningen zoals kluisjes en toiletten heel goed. Lekker eten en drinken. Iedereen is vriendelijk. Het museum is iets om niet meer te vergeten. Heel leuk, ik heb (in mijn eentje) genoten. Neem vooral de tijd om hier te zijn. Film kijken en beleven kost nu eenmaal wat tijd.", + "is_truncated": false, + "reviewer_name": "Martha Vuist", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVL9Bqg9aP9PJsCT03Hny12UyilSC0q7cfLSu4ORzU9EbP6No-RUA=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "Bewerkt: 6 jaar geleden", + "text_full": "Mooie moderne architectuur. De vaste collectie is interessant, interactief en mooi vorm gegeven. De wisselende tentoonstellingen zijn bijzonder om te zien met veel aandacht voor publieksbeleving. Het restaurant heeft mooi uitzicht maar is, net als het winkeltje, matig van kwaliteit en erg aan de prijs.", + "is_truncated": false, + "reviewer_name": "A Hooymans", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVG0drwFNMxCWkGdFRRPZ3c_8WCJ9BLtX6SQxfhglo5TgPz5_75=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Gisteren een prachtige film gezien met een snel hapje vooraf (vergeten te reserveren, gelukkig toch nog een tafeltje beschikbaar) en een drankje na de film met schitterend uitzicht op het IJ en Amsterdam. Vriendelijke bediening, toplocatie", + "is_truncated": false, + "reviewer_name": "Tina van Spall", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJ_0fybVYT9HakjfIBi3yu-0VL0bOXVYzzKljgxodVf-3AD-g=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "een jaar geleden", + "text_full": "Film theater met moviehouse films.\nLeuk restaurant met een fantastisch uitzicht over het IJ.\nParkeren in de garage vlakbij. Reserveren voor het restaurant hoeft niet. Voor de film wel.", + "is_truncated": false, + "reviewer_name": "The Villa", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVpv-SD0getDU6_aTsqfjzVWiQlDh4MWO5c3GYw6wk_nu4vpE8=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "Bewerkt: een jaar geleden", + "text_full": "Mooi gebouw met mooie faciliteiten. Minpuntje is wel dat je voor het restaurant moet reserveren. Dat was in het verleden veel prettiger.", + "is_truncated": false, + "reviewer_name": "Richard Boon", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWrDw7j_MIj8hCnzyTDCqbYGCIQM2bcjoCGjomQzT-Ybx5reBcG=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "12 jaar geleden", + "text_full": "Gegeten met 7 personen: lekker eten, goede bediening, waanzinnig uitzicht. We hadden gereserveerd voor binnen, maar omdat het zo'n lekker weer was wilden we naar buiten. Geen probleem. Bediening was attent en ook al hadden we bijna allemaal verschillende gerechten werd alles vrij snel opgediend. Het uitzicht is wel echt bijzonder, zeker met ondergaande zon. Binnen is het niet lawaaierig maar wel gezellig. Kortom een aanrader!", + "is_truncated": false, + "reviewer_name": "Taco van Voorst", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVwB-kjbfvDPW5XDSB-jlE88iuYESzU2DMLcqPx-hPIqaX5ZdeYHw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "De film Dichtbij Vermeer, bezocht voor het bezoeken van de Vermeertentoonstelling in het Rijksmuseum. Absolute aanrader, vooral deze combinatie in deze volgorde. Maar ook zonder de tentoonstelling zeer de moeite waard. Hulde aan Gregor, de held van deze film.", + "is_truncated": false, + "reviewer_name": "Marc Stemerding", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUfTQp4IcuroQN8bi8TeKPbF7b_hQnlVDJZiXf5sQ-YOVYDY2Gx=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "2 jaar geleden", + "text_full": "Klantonvriendelijk\nNetjes vroeg de man achter de kassa of wij een kortingskaart hadden en wees hij naar het scherm achter hem waar het beeld net verdween voor een reclame van Eye.\nWij dachten waarschijnlijk geen kortingskaart te hebben en toen wij af rekenden verscheen het beeld weer en bleken wij wel over één van de kortingskaarten te beschikken. In plaats van dat de man onze kaartjes omwisselde merkte hij nuchter op: “Dat is dan voor de volgende keer. Goed onthouden hoor.”.\nVan klantvriendelijkheid had deze meneer van Eye blijkbaar niet gehoord.", + "is_truncated": false, + "reviewer_name": "Roelof Ettema", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW0Q-fTOiek-Wc-EYmRv1RADBwFfc2p5SJx9cxq9re0BLRJ1rZw=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "6 jaar geleden", + "text_full": "Heel mooi gebouw. De vaste tentoonstelling vond ik zelf wat aan de magere kant. De expositie ruimte was wel heel mooi ingericht, maar niet echt een voor kinderen geschikt museum. (Hoeft natuurlijk ook niet). Geen films bezocht.", + "is_truncated": false, + "reviewer_name": "Marcel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJX_ePZnWEp9ZjzQhU9BlKGR6kk1-j9t6C87JrbpfvWX2pglQ=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "7 jaar geleden", + "text_full": "Deze mooie plek in Noord is niet voor niets tweede geworden bij een mondiale verkiezing voor moderne architectuur. Zowel buitenkant als binnen is erg mooi. De sfeer, de kwaliteit van het eten en drinken is ook fijn. Daarnaast biedt Eye een leuke variatie aan film en expositie aan. Bovendien is het goed bereikbaar met de pont! Een absolute aanrader.", + "is_truncated": false, + "reviewer_name": "Bastiaan", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocK1ys4XbtAsU8ohYBJaXiPiPkQ9M8RV8oUBw5c4NgJM6SaamSg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "een jaar geleden", + "text_full": "Mooie locatie, maar dit verbloemt de povere kwaliteit van het museum. De tijdelike tentoonstelling was wel de moeite waard.", + "is_truncated": false, + "reviewer_name": "Kees Huismans", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKWEIaiyYmZruBM-gOhLOUefAX79yE0DWWL4Q1wH871afnSVg=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "5 maanden geleden", + "text_full": "Prima filmtheater en lieve vrijwilligers 😗 …", + "is_truncated": false, + "reviewer_name": "Anton Buffing", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUcCYtVCLqJMLbf4ZqoAQ_TqF-O_EjqKYvOVN_jf9q1WD588a3M=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 4 maanden geleden", + "text_full": "Was een interessante film in dit prachtige filmtheater. Altijd interressant en ruim aanbod van film en meer.", + "is_truncated": false, + "reviewer_name": "Jan Runhardt", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUPrAAae8Ye52hcdM4KRfZRBj7hLklJ9TFE4XDnSLZ8wfx266K3=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Prachtige bioscoop, erg rustig - fijn publiek, mogelijk om zelf stoelen/zitplaatsen uit te kiezen in de zaal. Ook mogelijk om drankjes en eten uit het restaurant mee te nemen de zaal in.", + "is_truncated": false, + "reviewer_name": "Bruno", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXQrx7yMI5q5zGT5BFIPMz3lXjuSZCqGaqTgMNikxigDFt4j6yH=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "6 jaar geleden", + "text_full": "Slechts twee sterren voor het café dat inspiratieloos is en slecht functioneert. Bediening is aardig maar onhandig en onprofessioneel. Het aanbod ongeïnspireerd. Als je eens bedenkt hoe. Leuk deze plek zou kunnen zijn als je er echt goed zou kunnen eten of borrelen...", + "is_truncated": false, + "reviewer_name": "Janno Lanjouw", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWx_Ew1d8VnaRdUb5AE1EjWBw5x4lj3Z-iz5LQhYtVVu7dFQj2ouw=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "5 jaar geleden", + "text_full": "Een prachtig gebouw op een mooie locatie. Een restaurant met goed eten, wel een beetje druk. Bediening is goed. Wisselende tentoonstellingen en natuurlijk wisselende films. Pont vanaf Amsterdam Centraal station gratis naar de overkant.", + "is_truncated": false, + "reviewer_name": "Floris Mulder", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXvQvW-JTIoNiavhCWvvKMxKpUDqzNmZuySSAtB3QDv8R9djX6h=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Naar de expositie geweest van Ryoji Ikeda. Interactive kunst waar gebruik gemaakt wordt van geluid en beeld, met sinustonen, ruis en andere frequenties wordt een abstract beeld neergezet. Heel indrukwekkend om mee te maken en zijn verhaal erachter te horen.\n\nHet museum zelf is super toegankelijk en makkelijk te bereiken vanaf het Centraal Station. De architect van het museum heeft zeker zijn werk goed gedaan, het is een prachtig museum.", + "is_truncated": false, + "reviewer_name": "mad", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWq4G9SAAtO0rz09ndRnEmfZCBQHm62KZLJ5EMUdmeHBzOCKX9F=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "3 jaar geleden", + "text_full": "De permanente tentoonstelling valt helaas erg tegen. Erg weinig te doen en je kan met 30 minuten klaar zijn met alles. Het enige interactieve is de green screen.\nDe tijdelijke tentoonstellingen moet je ook maar liggen met films en rare kronkels van Guido van der Werve. Helaas een gemiste kans voor iets wat leuk en interactief had kunnen zijn voor kinderen.", + "is_truncated": false, + "reviewer_name": "Marc van der Sluijs", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW15m6QcbzGNbMtSaBDdgzlyuDVYAumDfet2IQjbfQGnME_o1dP=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Prachtige locatie aan het water. Ondanks strenge vormgeving van het gebouw erg studentikoze sfeer.\nHeerlijk films of docu's kijken die niet mainstream zijn.", + "is_truncated": false, + "reviewer_name": "Ronald Ramsodit", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocI8k5yFydgYe3ElVvz-URrMlTcbdYbrxah7KJEmXGFP8Gkacw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "3 jaar geleden", + "text_full": "Geweldige plek. Aan het IJ,leuk terras met geweldig personeel,zeer vriendelijk.Goede tentoonstelling, van alles over films. Lekkere lunch. We hebben een fantastische middag gehad.", + "is_truncated": false, + "reviewer_name": "Marianne Geel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVK9WVUDSw5oR7QDktJ3klvGbFO4Jk4LOXOXVV3st3n0XA5yV0=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "Bewerkt: 3 jaar geleden", + "text_full": "Feest! Bowie in het eye filmmuseum! wat een geschenk dat het eye filmmuseum bestaat met zijn ruime aanbod aan films, o.a. gerestaureerd! Gelukkig bestaat ook het eye filmmuseum restaurant. voor de film begint even langs daar en een wijn mee de zaal in (gelukkig een variatie van kwalitative wijnen, bijv. uit de rioja, en niet alleen maar van die bocht wijnen). een verzoek aan de bar servicemedewerkers die de mensen voor de filmzalen bedienen: beetje meer vaart erin kan geen kwaad! het duurt soms wel erg lang en ik zie regelmatig mensen in een rij staan wachten, natuurlijk ongeduldig en gestressd want de film begint zo.", + "is_truncated": false, + "reviewer_name": "Maria Heidemann", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXGa7q0cuKt6Zl1DlMvpArlqbf0ra5QmD8UftJ7OK4uKPAMuGcr=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Altijd goede films, je kunt in het prettige restaurant van te voren een kopje koffie drinken. Makkelijk bereikbaar vanaf de pont. Ook een leuke shop met filmposters etc.", + "is_truncated": false, + "reviewer_name": "Bonnie Rietveld", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVlz4fBhuglCX8cEwp8-sAJwK0TxIZ_dTIbKGzb7RtdAF98cYpF=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "9 jaar geleden", + "text_full": "Schitterend museum om te zien en te beleven. Alleen al voor het terras en uitzicht is het een bezoek waard. Een lunch is zeer zeker de moeite waard al kan het soms wel vrij lang duren. Er is een parkeerterrein achter het museum en tussen de bouw maar zonder navigatie is deze vrij moeilijk te vinden.", + "is_truncated": false, + "reviewer_name": "Jermy Leeuwis (drs. J. Leeuwis)", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXi9-a6ybA8Pn_ImVPcuYceDTi6fXwKOA77UNaYcBzcufkJc-ME6Q=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 3 jaar geleden", + "text_full": "Gezellig naar de film geweest. Was leuk. En nog wat gedronken. Vriendelijk personeel. Goede service 😀 En mooi uitzicht. Ik ben hier van harte welkom om met mijn kleine budget een alcoholvrij drankje te drinken. En werd snel geholpen. Ik zou deze plek zeker aanraden. …", + "is_truncated": false, + "reviewer_name": "Rewati Keesmaat", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUNUbJ-PC6fNJH5l79d2ekqVcGhxWlkKTjEORiQ9rt1Opt18g7M=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "3 jaar geleden", + "text_full": "Geweldige locatie met uitstekende bereikbaarheid. Interessante filminstallaties van Fiona Tan en dito permanente expositie.\nRestaurant uitstekend en hulde voor het enthousiaste en gezellige bedienend personeel. Top!!", + "is_truncated": false, + "reviewer_name": "René Splithof", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIs04V2OaubFZ-71zlwRqiw6ry2mFOKaNgJYLIj9ws-pOPX5A=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "5 jaar geleden", + "text_full": "Lunch was erg goed, behalve de koffie - machine slecht afgesteld, hoe moeilijk is het- en allerlei prima corona maatregelen, maar bedienend personeel houdt vervolgens geen 1,5 maar een halve meter afstand. Dat is overigens pas als je zelf maar even naar de bar loopt om te vragen of iemand de bestelling kan opnemen (want zoals bij veel horeca in Amsterdam, is men hier ook vooral met zichzelf bezig of met de collega’s). Het is bizar dat bedienend personeel in een omgeving die stijf staat van de corona-maatregelen naast je gaat staan en op een halve meter afstand je bestelling opneemt. En het blijft bijzonder dat de meeste horeca-eigenaren in Nederland niet proberen/er niet in slagen om hun Gasten snelle en vriendelijke aandacht te bieden (En een kwaliteitsbakkie). Kap nou eens met dat gepruts in de horeca, die nonchalance. Probeer eens te laten zien dat je blij bent dat mensen bij jou hun geld uitgeven. Heb een doel, focus en, eigenaren: stuur er eens op.", + "is_truncated": false, + "reviewer_name": "Jouke", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKykSjl54tbWlwIDZsmg3ZVuFTKKiaFpBXj-JGpnctWTt_ggw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "7 jaar geleden", + "text_full": "Prachtige locatie maar helaas niet lekker gegeten. Voorgerecht van tartaar van watermeloen had geen fijne smaak, de ham erbij was heerlijk, maar de portie zo klein, wordt gecompenseerd met een grote berg rucola. Salade met dungesneden entrecôte, remoulade, tuinkers, violetaardappelchips en frites, was een grote berg sla met een smaakloze saus en enkele plakjes zeer dun gesneden entrecôte... Prijs /kwaliteit was echt niet oké.", + "is_truncated": false, + "reviewer_name": "Inge De Reus", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVk0fokFj-lp0rEwNnAiaM9OkHv7jA1nJDsGryv5_U3Vew5MJg=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "7 jaar geleden", + "text_full": "We kwamen er met de klas voor een workshop. De leider/ster van deze worshop bleek niet aanwezig te zijn. We zijn dus voor 2 filmpjes vanuit Hummelo (achterhoek) naar het museum gegaan. Ruim 3 uur reizen met de trein voor een half uurtje film. Dit was erg teleurstellend. De leerlingen hadden zich goed voorbereid en hadden verschillende vragen. Ze hebben geen kans gekregen om ook maar 1 vraag te stellen. Erg jammer.", + "is_truncated": false, + "reviewer_name": "Tineke Bruil Raterink", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVQ9CBZyUcyawVfpdJpuux5i1iDnF2dLrcbNHqmGEgNI9WW-bgR=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "3 jaar geleden", + "text_full": "Ik heb een Indonesische film bekeken in het film museum. De film was leuk maar het geluid iets te hard. Stoelen reserveren was niet mogelijk. Het café is leuk de mensen zijn vriendelijk en het uitzicht is fantastisch.", + "is_truncated": false, + "reviewer_name": "Erik Luwen van Leeuwen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVej7NZT-EJerUSYOxHD-KhuA8Yrv3MsKtmnXStBLxJI5qzYcs=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "De tentoonstelling Liberte gezien, uitermate de moeitecwaard.", + "is_truncated": false, + "reviewer_name": "Ton", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWQsh4FsxSOy_qPUEicIXGmZAKBcQOxw3-ahZhXUxNYXYyZSag7=w36-h36-p-rp-mo-ba5-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tLxK8ZU1CTpeZzMiEXefKw0WhgJx0ycc9DJ4Puovc5e_1B5oFRa6Xi9iCuW1DcPV0oaXRx1g4TfAOWC1l-Ya2UmkX9hhMlXuL35yAV_a8NelB9W5A0qz5nz9w6qzb9R9u8G7k=w600-h450-p" + ] + }, + { + "rating": 2, + "relative_time": "5 jaar geleden", + "text_full": "Nog steeds is de bediening beneden alle peil. Mijn bier werd drie keer bijgetapt, op de bar gezet en weer vergeten. Onderling hadden de medewerkers het erg gezellig. Maar daar heb ik niets aan als je bier niet wordt geserveerd.", + "is_truncated": false, + "reviewer_name": "Peter van de Westeringh", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWNIGkDCQULtP6KfAhyNASsBxxhvZp9sqpxqe8GQYPVXJldBoXrGA=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "8 jaar geleden", + "text_full": "Een heel mooi en bijzonder filmmuseum met 4 filmzalen met films die je altijd nog eens wilde zien. Het gebouw is heel futuristisch ook vanbinnen. Het uitzicht door de grote ramen van het restaurant op Amsterdam en het Centraal Station is prachtig. Neem je camera mee. Parkeren doe je onder het gebouw. Voor rolstoelen en slecht ter been heel goed.", + "is_truncated": false, + "reviewer_name": "Rudolph Hoeben", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJ-Mz0QfeXQ8ornbWXaxiy-AXFgJ5RocZzdLPEuLZmcGlce2A=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Wat een geweldig gebouw! Wat een aparte architectuur! En van binnen stelt het op zijn zachtst gezegd ook niet teleur. Ik kwam hier op uitnodiging van de (voormalige) Directeur van Cultuur+Ondernemen in Amsterdam, die afscheid nam en waar ik van 1999 tot 2012 heb gewerkt.\nKomende vanaf Amsterdam CS maak je een oversteek per gratis pont naar de overkant. Vanaf het water zie je Eye al liggen. Het ligt direct aan het water van het IJ. Voor ons werd een speciaal programma samengesteld waarvan de filmzaal en de borrel hoogtepunten waren. Om alle voormalige collega's weer te zien, voelde als een warm bad. Omhelsd worden, zo hartelijk... alsof ik nooit weg was geweest.\nToen ik de terugreis wilde aanvaarden, had ik mijn jas en tas in een locker gedaan met een plaatje erop. Vergeten welke locker het was! Het personeel aan de kassa heeft de lockers geopend tot de juiste was gevonden. Compliment!", + "is_truncated": false, + "reviewer_name": "MIRJAM Wintermans", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWiqKs4UUgJsUnNIfqPXLk-PeLKxw5XcRBayUJFPD0XTGr1raxL=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "3 maanden geleden", + "text_full": "Prima, bescheiden museum.", + "is_truncated": false, + "reviewer_name": "Maik van Bruggen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVU5UA7yBNczrW7XfMeKzyr8wyfLVrerwYY3TYN8ylek1MhIak=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "Bewerkt: 6 jaar geleden", + "text_full": "We haddden medio januari 2019 kaartjes voor een avondfilm maar hadden ons in de dag vergist en waren een dag te laat. We vroegen of we in de plaats hiervan naar een andere film mochten. Dat was allemaal heel erg moeilijk en lastig. Normaal gesproken zou ik al weg zijn maar mijn vrouw deed nog moeite en uiteindelijk mocht we naar een andere film. Uiteraard is deze locatie qua restaurant en bioscoop top maar de service zou toch wel wat prettiger kunnen.", + "is_truncated": false, + "reviewer_name": "Harald Danser", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWg-MNiaXkgdtSfZVkOKAh01TeE9_E01OifgBKqf52HLwqTHIoxVA=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "Bewerkt: een jaar geleden", + "text_full": "Klein, maar verder wel leuk. Je kunt er ook prima eten en drinken.", + "is_truncated": false, + "reviewer_name": "Rick", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUwAwa1N-l0IsOXSutUJRu0pmP1lcdhv1pn8CYxSOa1wkvs-F5O=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "2 jaar geleden", + "text_full": "Een fijne zaal, maar wat ik miste was duidelijkheid over waar ik een drankje etc kan kopen.\nIk neem braaf zelf niks mee, maar kon nergens een balie of foyer die open was vinden. Achteraf bleek dat in het restaurant drankjes en snoep voor de bioscoopbezoekers verkocht worden.", + "is_truncated": false, + "reviewer_name": "Monique Laarakker", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVFsjns5_IVNuiObZDrmBTh5b0LINRixdqmTOQxBYejFm7AriaXSQ=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "3 jaar geleden", + "text_full": "Helaas viel het erg tegen, er was heel weinig te zien. Ook het restaurant is erg slecht, de bedieningsmedewerkers kijken de gasten niet aan. Koffie zonder melk en suiker, geen koekje bij de thee. Erg jammer van zo’n mooie locatie.", + "is_truncated": false, + "reviewer_name": "Paul Spannenburg", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW50DmyWh1Rk4Aq0NQxElyLR5EWc7YsBf_T8P2uwlJErvT9Vj9x=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Prima museum, vond het niet dusdanig bijzonder. De tijdelijke expositie was erg apart.", + "is_truncated": false, + "reviewer_name": "Ramon", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWoRmgPiyOLxIYgGXCoXKE6HKyTsgRVOEsr6KipFZWvC6JpZM1t=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Kom hier paar x per jaar eigenlijk alleen voor het restaurant ivm afspraken om even zaken in een conversatie met derden af te stemmen. Mooie open zitgelegenheid met open views over het IJ richting het westen (dus richting station centraal. Vriendelijke bediening.", + "is_truncated": false, + "reviewer_name": "Geert van der Leest", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV1Y7zzJOG_O8lkvKEzcbW8OwJRpFvKlcP5NigTh_xY98chrvQK=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "3 jaar geleden", + "text_full": "Blijft jammer, dat er geen permanente collectie is, zoals andere filmmuseums hebben. Architectonisch mooi, maar veel verloren ruimte, vooral in het altijd overvolle cafe.", + "is_truncated": false, + "reviewer_name": "Robert Elliott", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJX6Y4bWPGgHNkhoFW3wSvmZgo6AJQrjZfgVA_D4XO0-gf33Q=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Mooi gebouw en leuke collectie van films, alleen horeca is nog steeds belabberd georganiseerd…", + "is_truncated": false, + "reviewer_name": "tom leeuwestein", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXmVMsM-WG-LvzCJtDVOo2sqPvSn4zf9yo3X0xm7i7iStG10X8=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "5 jaar geleden", + "text_full": "Vandaag Tenet gezien in Eye. De voorstelling van 15.45 uur. Er vielen helaas een paar minder positieve zaken op.\nTwee bezoekers wachtten zonder mondkapje voor de ingang van de zaal. Degene die de kaartjes controleerde stond er bij, maar zei er niets van.\n\nNa het begin van de voorstelling werd er toch nog publiek binnen gelaten. Zeer storend voor de mensen die op tijd waren en al van de film aan het genieten waren.\nDrie jongens kwamen binnen tijdens de voorstelling, wilden plaatsnemen op plekken die daarvoor niet bestemd waren. Tijdens de film waren ze continu met elkaar en hun smartphones bezig.\n\nHet scherm ging gedurende de voorstelling zeker 3 minuten op blank. Het geluid liep door. Toen er weer beeld verscheen hadden we drie minuten film gemist.\n\nKortom, helaas niet echt een geweldige bioscoopervaring vandaag.\n\nWel een prima film overigens.", + "is_truncated": false, + "reviewer_name": "Peter Bongers", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXMEwR8Zu9QsRRA-zA9mBjrlwG5XFG2xq6ViEb0rEIH3JusyyxY=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Het viel mij erg tegen. Het is vrij klein. Ik had er persoonlijk wat meer van verwacht.", + "is_truncated": false, + "reviewer_name": "Peter Grootendorst", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXnubAXDFZj5RZOgZy5qoIA99WoQqjXyeOug-7ubblEuiXL_gDlMA=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "7 jaar geleden", + "text_full": "Waanzinning gebouw. Telkens als ik hier kom vind ik het weer 'pure Star Trek'. Mogen trots zijn in Amsterdam op zulke moderne architectuur. Loop altijd even de trap op om naar het restaurant beneden te kijken. Bediening is aardig maar soms ook een beetje passief. Eten en drinken is gewoon goed.", + "is_truncated": false, + "reviewer_name": "Joost Nolden", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIOvDLniLbMAT96i-cKHFVbofL4DIltEJr-F_4m68sIuxKbOw=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "7 jaar geleden", + "text_full": "Het eten wat opzich prima, maar niet glutenvrij zoals beloofd was. Ik heb meerdere keren gevraagd of het glutenvrij was, maar er was een fout bij het toetje en ben nu ontzettend ziek geworden. Als je geen allergieën hebt is het wel goed, alhoewel je er op moet rekenen dat de service voor Amsterdamse begrippen zeer traag is en de porties klein.", + "is_truncated": false, + "reviewer_name": "Merel K", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJM7pc3yXJYAewBwTUdwyXCgKFO8bN2N0th2cVRdKVT0RspFQ=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Oppenheimer ! Eye: mooiste terras van Amsterdam. 70 mm vertoning Oppenheimer indrukwekkend. Geluid wel te hard! In de dialogen storend. Voor de kernexplosies ok voor een paar keer, maar te vaak gaat ook storen. Wel 'Eartquake ' ervaring. Boeiend verhaal.", + "is_truncated": false, + "reviewer_name": "Alexander van Ballegoijen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWzI3zPbT0owh3bQ4AZhIW_MPhqcLmb2rrfrN6ZwcH7JA19Cdv2=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Het museum heeft niet bijster veel om het lijf, het is nogal aan de kleine kant en filmexposities zijn sowieso een vak apart. Het café is zeer prettig, met name vanwege het grootse uitzicht over het IJ.\nMaar de reden waarom je echt naar Eye zou moeten gaan zijn de geweldige filmzalen. Als je een film in Amsterdam wil kijken op een groot scherm en in een luie stoel: dit is waar je moet zijn.", + "is_truncated": false, + "reviewer_name": "Koen van Atrecht", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLotgfLf2TsUVV7GnO9Xx4IkTXg_qgKDyPAmSuuuqI9AafEJA=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "het was mooi ,,, gebouw en de museum winkel,, met de vele dvds waar niks voor mij was,,, en het restaurant waar je ruimtelijk kon zitten ..en het uitzicht ...🤪🌄 was mooi ..ik kom nog weleens terug....groet van een bezoeker die daar wo jl daar was .....", + "is_truncated": false, + "reviewer_name": "marius de vilder", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLYpsGyuxcWbcWFYUdd3bUhwzFm_9AFPPsZtqXkpkyTKae_Tg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "5 jaar geleden", + "text_full": "Prima restaurant, museum en bioscoop in één. Echter vind ik het wel zeer jammer dat er niet makkelijk versnaperingen voor de bioscoop geregeld kan worden. Dan moet je weer per se een reservering voor het restaurant hebben. Simpele snacks zoals popcorn hebben ze niet.", + "is_truncated": false, + "reviewer_name": "Lucas Wagenaar", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWFnItEpDiXyUg5dTfvzoK_7fUcM8SaKlGgcTTU3JRPQXO7ShqP=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "9 jaar geleden", + "text_full": "Een fantastisch gebouw dat alleen al daarom de moeite waard is. Prachtig uitzicht op het IJ vanuit het gebouw. De expositie Close-Up in het museum was de moeite waard, maar had wel een hoog fragmetarisch postmodern gehalte. Daar moet je tegen kunnen. De permanente expositie is leuk, maar erg beperkt.", + "is_truncated": false, + "reviewer_name": "Maarten Wisse", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW0hCY1KG3tSmbU_hSKDPU-8q7LV2D_rhVDXR4w4zusIGYVOfB42A=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "3 jaar geleden", + "text_full": "Schitterend gebouw, leuke locatie en goed bereikbaar met het pontje. Leuke dingen voor hele familie in het museum, filmzalen zijn top. En daarbij zeer te spreken over de vriendelijkheid en service van personeel.", + "is_truncated": false, + "reviewer_name": "Steven Lemain", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXr64rmNn-LPdEFKfIw_viTYu-y0-eiiPixlJl-jGbckoAozhs=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "7 jaar geleden", + "text_full": "Super interessant museum. Erg interactief en daardoor leuk voor alle leeftijden. In sommige ruimtes kan het licht vervelend zijn. Mensen die daar gevoelig voor zijn kunnen snel overprikkeld raken in dit museum. Er is veel flitsend licht en geluid van meerdere kanten tegelijker tijd. De tentoonstellingen zijn voor mij zo leuk dat dat me niet tegenhoud om te gaan kijken.", + "is_truncated": false, + "reviewer_name": "Femke Boon", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXmUwJtCQRz_a9SELGwVB0ctBPvevKAohrl1fVKWx9VHYzuNdjG8g=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "7 jaar geleden", + "text_full": "Gezellige plek om tot rust te komen in deze drukke stad!", + "is_truncated": false, + "reviewer_name": "D R", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVhPA4yivGuP8lzIQ_na0jnYyIF60NYhEKStMSBxnk9KzGVoXaL8w=w36-h36-p-rp-mo-ba4-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38szX_mKoyxSLv1OXF6PTBk2SMgArtc2v4mLatqGe65JxgDWHpts_7Le8xtQOqwAgq1eUeQN3fveCpJ_QsNOHKuMuU1mUHUlvJvJvW3DF6L1HI42vQ9_mEcCDvkuuMAHdDfN4vOv=w300-h450-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tCIqnwZCLyK5WT4N1Orrd26PsFRk860Y2Q4rWXP6Ovs3guYtCaMoEX77eBVOwnfhedDjFgA7ri2iR6tm8365Yc-Vy4KW6SEqxCX-tEf1Qq4cVBicj2-V7lyfLVqSmybVDckhpi=w300-h225-p", + "https://lh3.googleusercontent.com/geougc-cs/AMBA38tBwN8VPlyeeZaVzW1OX2WDlejx-eOIsWb9CQuisecWDjV2FdSMEpsqmC-7ukaJKGNHDJdYWjwV5UEEFfB5jSfT20j9hM-f6t0WW41EwQOWWwGqy7r0KsQ9v30DJ6ZOOsq3NUDN=w300-h225-p" + ] + }, + { + "rating": 2, + "relative_time": "een jaar geleden", + "text_full": "Als dit het museum van de film is. Stelt de geschiedenis niet veel voor. Zo een heel groot gebouw met weinig expositie", + "is_truncated": false, + "reviewer_name": "marjon voskuijl", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIN6YxdPO3kMnJrqntWyVRHtNGpaPUeBQtaXYfgV29dpmNLog=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "8 jaar geleden", + "text_full": "Ik zou zeker als je dan toch in Amsterdam bent een kijkje in dit museum doen. Het gebouw is erg ruim en modern. Binnen is er een gezellige eetgelegenheid en beneden is er een tentoonstelling die je gratis kunt bezoeken. Deze tentoonstelling presenteert de filmgeschiedenis en gebruikt daarbij moderne elementen. Hierdoor wordt het aantrekkelijker voor kinderen.", + "is_truncated": false, + "reviewer_name": "Adrie Van Wingerden", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWfUsuSE_PYNOvaaZMH8R2qQRwfr7ROaQZ8I0aD124tuN6KmV11=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Een lunch met 10 personen in het restaurant, prima verzorgd, helemaal niet rumoerig, de ruimte is akoestisch uitstekend. Goede lunchkaart ook.", + "is_truncated": false, + "reviewer_name": "Max Carbaat", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWZDVC7rt91wljXwPNCXS-j_9tgMKM-7iTWBL0Bex_7_5xqT01Xdw=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "8 jaar geleden", + "text_full": "Leuke tentoonstelling over martin scorsese gezien. We moesten 3 euro toeslag betalen bovenop onze museumkaart, maar het was het waard. We hebben 2 uur rondgelopen en nog steeds niet alles kunnen zien.", + "is_truncated": false, + "reviewer_name": "Arwen Matthijssen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVL6dS1uT-_9uTp6MdAAqRuUl35B8WDOxoRHt9xIz3Ck_fB7_kb=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "7 jaar geleden", + "text_full": "Het is heel cool en het uitzicht is prachtig ik heb veel nieuwe dingen geleerd en gedaan", + "is_truncated": false, + "reviewer_name": "Hannah Fokkema", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJ1UEsA2vlkYQ_f6Q93JCeVDfY2Ia8j59zZnC2LvQTBppwPgg=w36-h36-p-rp-mo-br100", + "images": [ + "https://lh3.googleusercontent.com/geougc-cs/AMBA38vqnFBm85sbfaxTXIceH_t6k0S_U26DquIy5jfQ78eumzi4bhOu9Qz5aRT5QY-63_SUGlelwfY9AbFSHKvDHXPVc2HC7g_t5g1I8YrGNuk4tJh6ETNTY1A-bHtlrQsnnVQ0hdj9=w600-h450-p" + ] + }, + { + "rating": 2, + "relative_time": "7 jaar geleden", + "text_full": "De vaste tentoonstelling heeft volgens mij niet voldoende inhoud. Te weinig toestellen. Te weinig over de geschiedenis. De wisselende tentoonstelling toonde verschillende kortfilms tegelijk in 1 ruimte van 1 bepaalde regisseur. Niet echt handig want je begint telkens halverwege een film te kijken. Ik had er veel meer van verwacht.", + "is_truncated": false, + "reviewer_name": "Nico Arnouts", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJhqWcC6x_y_vErUSCLvLD_gEa24so_6Vs-neKkmJED0VTF_A=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "3 jaar geleden", + "text_full": "Ik heb hier gegeten. En dat was prima. Het is vrij hoog en groot, maar goed het restaurant is onderdeel van een filmtheater dus verwacht niet teveel knussigheid. Het eten was gewoon lekker. Punt", + "is_truncated": false, + "reviewer_name": "Lara", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocId52ma1sdocgZHXvEznGN413zPcrySsHaRESiesdvkXgFtnw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "6 jaar geleden", + "text_full": "Zaal 1 is geweldig als bioscoop is Eye prima te doen. Het cafe daarentegen is matig. In Amsterdam zie je het helaas teveel maar men bedient je met een gezicht alsof het einde der tijd nadert. Bedienen lijkt mij ook een vreselijke baan maar waarom doe je het dan? Ik heb je niet gedwongen. Als je het dan toch doet, doe het dan met wat plezier! Sowieso is het serieuze gezichten gehalte hier erg hoog. Mag kunst niet leuk zijn?", + "is_truncated": false, + "reviewer_name": "Maarten Mulder", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXASSh4XdwAD3lSatEIj7w58HUPuQ9LIQ3Yz9hVpvjG6Eu0GtJ6=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Prachtig museum, leuke plek waar je oude en arthouse films kan kijken en kan dineren met één van de mooiste uitzichten op het ij.", + "is_truncated": false, + "reviewer_name": "Zoe Rademaker", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVWVlPp6rTpMCrRLtPgpa0lWfaRQ4ruRapuKHSOX04Bxt5L8iNnFQ=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Mijn favoriete museum, niet alleen voor de films en tentoonstellingen. Ook om af te spreken.", + "is_truncated": false, + "reviewer_name": "Jean Charles C", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjX6cqcrZZx1JtERPsZncXj7WylXAaGXPm1dFTileWxv8SU_vqPe=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Boeiende architectuur; van buiten en binnen.\nMaar echt verre van overzichtelijk.\nWat is museum? Wat zijn de filmzalen? Waar het winkeltje?\nEen kat in een vreemd pakhuis.\nGoed, het komt wel; al vragend en met een papieren gids in een kleurrijke folder.\n\nHet heeft wel indruk gemaakt en achter gelaten.\nOf het van een tweede keer... zal komen?", + "is_truncated": false, + "reviewer_name": "Harry Daudt", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWUbA7yFs_u_F0i_geC2jxSvb9NT5-AIZbb6hgGjV06umOHVNeJow=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "10 jaar geleden", + "text_full": "buitgewoon mooi gebouw. tentoonsteling ruimten zijn goen en groot.\nhet klein museum waar ook wel zo door heen ben.\nkluisjes zijn genoeg. ook voor kinderen is veel te bleven. met bicoop zaal er bij is echt top.\nwc zijn schoon en goed verzorgt.", + "is_truncated": false, + "reviewer_name": "EVERT VAN WELIE", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUfaRhUfl1EkQ9CIEklu0Q-dnzWfC3FBtGXPHI3Ba6ryZ28Q-Zp=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "8 jaar geleden", + "text_full": "Prachtig gebouw met een zeer indrukwekkende inhoud. Er is in de kelder een gratis gedeelte waar je alles te weten komt over film/camera's. Om de zoveel maanden hebben ze ook een tentoonstelling mbt een regisseur/genre/film (let op: hier zijn wel kosten aan verbonden om dit te bezoeken). Daarbij hebben ze ook nog 4 bioscoop zalen waarbij zaal 1 een speciale 70mm projector heeft. Na The Heightfull Eight draait nu Dunkirk in dit speciale ratio. Erg indrukkend en ook de enige in de Benelux. Ga je Amsterdam bezoeken en ben je fan van films/camera's en de historie hiervan: Zet dan zeker het Eye Filmmuseum op je planning!", + "is_truncated": false, + "reviewer_name": "Tom V", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjU4zCPM701iMDhyx6LFct-RHIT9UKgQiL6AcUPRb7A-cH7Za9YQGA=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "4 jaar geleden", + "text_full": "Groot gebouw maar het gedeelte van het museum is maar heel klein. Het gebouw is vooral filmzalen en restaurant. De tentoonstellingen vind ik vooral voor insiders, voor het gewone publiek is er weinig leuks en moois.", + "is_truncated": false, + "reviewer_name": "Ron van Bruchem", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVJKpGXAykovEYf6D4i-HJIRln0ZbQlr0EIkqS-kHxbgsj79L0=w36-h36-p-rp-mo-ba8-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "3 jaar geleden", + "text_full": "Prachtige, nog steeds wat futuristische omgeving, geweldige catering tijdens pauze en eindborrel met heel vriendelijke staf! Ook de zaal, waar het congres werd gehouden was goed.", + "is_truncated": false, + "reviewer_name": "Diederick Wouters", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWMM-3EAMYGZv8FRon3akrmF7RZ_Di8Z8oB3rkk4cWaCGYG-Qr1nw=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "7 jaar geleden", + "text_full": "Prachtig museum, zowel het gebouw als de voorstellingen. In het museum zijn er ook filmzalen waar je tegen extra betaling een film kunt zien. Er zijn ook combitickets verkrijgbaar bij de kassa. Parkeren kan in de garage onder het gebouw. In het museum zijn gratis kluisjes om je spullen op te bergen.", + "is_truncated": false, + "reviewer_name": "Satish Doekhie", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWxFC59tzfOGi59-4L5dbeqVU3zLSVOhG3WJ4WGLGLiEboGBs5p3w=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 2 jaar geleden", + "text_full": "Drive my car gezien. Een geweldige film over eenzaamheid en dingen. Het deed me denken aan Zen en de kunst van het motoronderhoud. Een zich langzaam ontvouwend verhaal waarin mensen om elkaar heendraaien en om een ding.\nIn Drive my car is dat een Saab 900 turbo, in Zen is dat een Honda CB77 Superhawk.\nWat mij betreft had de film iets eerder mogen eindigen. Een beetje rijden met de Saab, kijken naar de hoofdpersonen, het bijwonen van de repetities voor Oom Wanja van Tjechov was genoeg geweest.\nDe levens hadden elkaar geraakt en zouden doorgaan. De film werd nu afgesloten met een eindshot van Oom Wanja in het theater. en de chauffeuse die de Saab krijgt.", + "is_truncated": false, + "reviewer_name": "jan l", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJ6JITLy9Kok0iaHH6Ed1HYWjK4NRvrApY00awslTWRDbrA2g=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "9 jaar geleden", + "text_full": "Fantastisch gebouw op een fantastische plek. Prachtig uitzicht over het IJ. Interessante programmering. Wij zagen Grijs is ook een Kleur, geregisseerd door Marit Weerheim, productie Eva Verweij, Loes Komen en in de hoofdrol Cécilia Vos en Ko Zandvliet. De bediening in het restaurant prima. De communicatie met de afdeling kaartverkoop laat te wensen over (eigenlijk afwezig).", + "is_truncated": false, + "reviewer_name": "Marcel Vos", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWVazyxHIKwnYd6JvTipJj3F4rNOLQ6cwEjL1L1HY0gwQkCmKjPnw=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "8 jaar geleden", + "text_full": "Schitterende locatie met prachtig uitzicht. Mooi om een klassieker als goodfellas op het witte doek te zien. Drank en hapjeskaart wat summier en rij was erg lang, niets lekkers voor de film kunnen halen helaas.", + "is_truncated": false, + "reviewer_name": "Rejean Kriek", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjU7NczuIghN3s20cajo2X7wA5LlNRiw4wuVFunL4qNGYKPb_3Q=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "3 jaar geleden", + "text_full": "Een bioscoop, museum, souvenirs winkeltje, balie/info, wc's en horeca in 1 groot gebouw. Keurig, overzichtelijk en vriendelijk personeel. Bioscoop heeft redelijk grote zaal met vrije keuze waar te zitten. Leuk om te bezoeken.", + "is_truncated": false, + "reviewer_name": "Marianne Biesheuvel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWGnA3wT-lR7Z_UvDpuq45FqwBtKTm4qGWYgJAbELITbT1U5sIg3Q=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "7 jaar geleden", + "text_full": "In de meeste bioscopen zit ik niet echt lekker, maar hier uitstekend! Ruim opgezet, goede stoelen. En de bar werkt uitstekend, je hebt alle tijd om iets te bestellen in de pauze want men werkt zeer efficiënt.", + "is_truncated": false, + "reviewer_name": "Ralf de Jong", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV5GENtv5VIVjLudbiVVlDMFwe3psAqtfzozI2ovEaJTafu7vomjw=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "een jaar geleden", + "text_full": "Leuk, maar we waren sneller erdoorheen dan verwacht. Alsnog leuk om even rond te lopen.", + "is_truncated": false, + "reviewer_name": "M. L.", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV1MGAXqB6nNBWAePAjE-6i5sNfk1HFxSIAm308mwbUhChS8b40=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Een prachtige locatie op het Ij, het café is zowel binnen als buiten gericht op het water. Er komen vaak prachtige schepen langs. De films zijn uiteraard top en dankzij hetuseum is het een alom mooie ervaring hier te zijn", + "is_truncated": false, + "reviewer_name": "Ka van Brakel", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXRAKI9WmO9HkijogdcHj_y-2J6TmPa2DfnAWBdaX_hyxJGHA0Fhg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "10 jaar geleden", + "text_full": "Schitterend om binnen te zijn: veel ruimte, prachtige films en last but not least, zalen voorzien van top stoelen en eigenlijk de beste projectie in Nederland. Het filmaanbod is heel mooi, gevarieerd. Van klassieke films die je kunt herontdekken to de laatste goede Nederlandse film. Prachtige tentoonstellingen en een goede café/restaurant. Ik zou graag naast EYE willen wonen :)", + "is_truncated": false, + "reviewer_name": "Heddy Honigmann", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJdKiRrnz1D9WgFc099Mcrz1Ak56-KXLSlqXsjPE-8xidRsug=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "7 jaar geleden", + "text_full": "Erg mooi museum, de filmzalen, beeld en geluid zijn prima. De brasserie serveert uitstekende gerechten en de service is allervriendelijkst. Ook voor de kinderen valt hier voldoende te beleven.", + "is_truncated": false, + "reviewer_name": "Arjan van Ek", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXfqKFtdfK53Ri-aLjpzexMWo6LZ6kxtsYwVDcm42M98HEZduXr=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "7 jaar geleden", + "text_full": "Fijne zalen met een breed scala aan genres en titels. Onder is een klein, maar zeer educatief, museum over de geschiedenis van de cinema. Restaurant met vriendelijke bediening en door de ruime opzet mooi uitzicht over het IJ. Visitekaartje van Amsterdam.", + "is_truncated": false, + "reviewer_name": "Jheronymus Boos", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUOwWXQg9--nm3rvx3vm8VFgZmIOlFckZBybcK-Qn_tN5uuvLw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Eye is een leuk en prettig museum. De tentoonstelling van Saodad Ismailia is echter een misser: een hoop onprettige geluiden en veel te veel oninteressante beelden door elkaar. Zeer onprettig en oninteressant. Een van de slechtste tentoonstellingen ooit.", + "is_truncated": false, + "reviewer_name": "lot wijlhuizen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXhNBHeN3QgHljdFM5NmbsR1MUNh0JPKmsxNaFPOEFdTNQt7-w=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "4 jaar geleden", + "text_full": "Helaas vanwege de Coronamaatregelen het museum niet uitgebreid kunnen bekijken, maar het gedeelte wat ik gezien heb op weg naar het toilet (ik was er voor opnames van het tv programma First Dates) zag er veelbelovend uit", + "is_truncated": false, + "reviewer_name": "Jan Vermeulen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjU5IjTt7XgQoiqyAD7pIbGRM89P3TaHOqLGQMMgk8Nwo1JwnBo=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "8 jaar geleden", + "text_full": "Ontzettend leuk om alleen al koffie te drinken. Hier ook goed gedineerd en geluncht. Natuurlijk met de kinderen zelf naar films en documentaires geweest. Vaak leuke kinderactiviteiten en ook de moeite om heen te gaan voor de exposities en gratis toegankelijke permanente film gerelateerde installaties.", + "is_truncated": false, + "reviewer_name": "Brendan Thesingh", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXtbZtWsMnioTdyoI7khsdNZ-sa9-f5sxFmuj3x7BYEI5MOf_nz5w=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "3 jaar geleden", + "text_full": "Imposant gebouw, ook van binnen met fascinerend panoramisch uitzicht vanuit de \"arena\" op het IJ en de overkant. Gevarieerd filmaanbod (4 zalen), filmfestivals, leuk interactief museum en speciale afdeling voor wisseltentoonstellingen op het snijvlak van film en - kunst", + "is_truncated": false, + "reviewer_name": "R Van der Kloor", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocISsJJN3fP22vXPdgwT8yYHmwvQqkXT70cQ55tYl4ChyrOXy3k=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Erg leuke museumdocenten namen mijn klas mee door de geschiedenis van film. Dit deden ze op een enorm leuke manier.", + "is_truncated": false, + "reviewer_name": "Sonja Vermeulen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWR3IgcQ_9HExiaay0nrf2IVevlEdBPg5s_WJS2oWgTH38YOJsw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "7 jaar geleden", + "text_full": "Prachtig gelegen aan het IJ. En restaurant dat er mag zijn, perfect om voor de film wat te eten of te drinken met fantastisch uitzicht. De zalen zijn modern en comfortabel en dan als bonus de terugtocht met pont over het water. Doen!", + "is_truncated": false, + "reviewer_name": "Casper Le Fevre", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW-oBw_lKBRaMOYhqi0pAB3Y-ktqnV7hcGtqVnyTl-jX7fpMYQ=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 3 jaar geleden", + "text_full": "Zaal 1 is de beste zaal van Amsterdam zeker met 70mm. Je kijkt nooit tegen iemand aan, beeld is geweldig, net als het geluid. De bar met uitzicht op het water is erg sfeervol.", + "is_truncated": false, + "reviewer_name": "Coen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUZ5a7DkcUXkpxxRzVfJG8WnMZEjz8vzL_EVu84LlPZVvRZeDd9Sw=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 2 jaar geleden", + "text_full": "Indrukwekkend mooi gebouw met een mooie programmering van films. Ik heb er op Klik Festival de film \"In This Corner of the World\" gezien. ...\nEn nu \"Oppenheimer\". Indrukwekkend. Nog meer omdat ik ook in Hiroshoma en Nagasaki geweest ben en gezien heb wat de schaal van de verwoesting is geweest...", + "is_truncated": false, + "reviewer_name": "Ramses Rodenburg", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW6B2jSj7N17bDiu-sJQRHoGbS7AACmgtpY3P04OUP1Gvmo1yL2nA=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "12 jaar geleden", + "text_full": "Eye is altijd een belevenis: het uitzicht is fenomenaal op de mooiste plek van Amsterdam, het café is gezellig en beschaafd, het eten is er erg goed en de films, dat spreekt voor zich. Nergens kun je beter je tijd besteden op een regenachtige dag dan door een expositie in Eye te bezoeken, een film uit te zoeken en te mijmeren over het water. Als het weer opklaart kun je je hart ophalen met een wandeling over het Overhoeks terrein, hip en stads aan de overkant van het IJ. CS dichtbij, een tochtje met de Pont, wat wil je nog meer?", + "is_truncated": false, + "reviewer_name": "Martina van Campen-Wierda", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXFGyhzfUuRa2eijFog7p-3dYzn88HBqvRE0_WhPMwJ7iOiT_xa=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "6 jaar geleden", + "text_full": "Heerlijk gegeten. Zeer vriendelijke bediening vooral de blonde jongen (volgensmij hete hij Dirk) nam ons zeer hoffelijke onder zijn vleugel. Prachtige locatie en we zullen snel terugkeren!", + "is_truncated": false, + "reviewer_name": "Clemens Alting", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJu-3ZErqMztOfEZu5GpLBWj7XoQUjOOd4pCl988CbpTRJ7Sw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Prachtig gebouw om naar te kijken. Je bent er zo vanaf centraal station met de pond. Die om de zoveel minuten weer vertrekt. Echt een aanrader om even langs te gaan. Ook is de A'DAM lookout gaaf om even langs te gaan als je er toch bent!", + "is_truncated": false, + "reviewer_name": "Julius", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIYUM3U5pcoyc8soNRDwu9WvEDelIVaJ6GHqWR5Q00OFtXiVwIw=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Tentoonstellingen zijn echt 5 star. Als het je smaak is tenminste (vaak experimenteel, vervreemdend, bizar, modern en multimedia).\n\nBioscoopzaal wegens doorgezakte, oude stoelen echter tegen onvoldoende aan, op een gegeven moment weet je niet meer hoe je moet zitten. Jammer dat het gebrek aan comfort afleidt van het overigens prima aanbod van films.", + "is_truncated": false, + "reviewer_name": "Pieter", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWIa-UMBa37Eu3yxi9sOqreTPky6YYj4WjjvgwcOnFaI05YzJKRLg=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Mooi museum en sfeervolle bioscoopzalen met een ruime selecte aan films. Restaurant heeft prachtig uitzicht over de Amstel, maar is helaas wle prijzig en de bediening wisselt sterk in vriendelijkheid.", + "is_truncated": false, + "reviewer_name": "Joël", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJv_N5Xph4iZ2t-MYjxKfX7-th7iR9JGUbtwYjN1oivEUpjRQ=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "8 jaar geleden", + "text_full": "Prachtig gebouw met een fantastisch uitzicht over het IJ. Interessante thema's in het museum deel als je van film houdt. Onderin een gratis toegankelijk deel over camera's en filmtechnieken. Er worden mooie films getoond. Ik ben in het bijzonder fan van Cinema Egzotik. Een double feature met een thema.", + "is_truncated": false, + "reviewer_name": "Jeroen van Waveren", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjX1eIz3V6-WMJj32wBHySFTKHduF8TAovyeAYVmCdm5sac88Nl3=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "6 jaar geleden", + "text_full": "Het is een zeer groot museum maar zelf vond ik het toch niet heel erg bijzonder. Wel wat grappige interactie plekken maar ik vond de tentoonstelling helemaal niet interessant. Ik heb hier ook nog een film gekeken en de kleine bioscoop zaaltjes zijn wel fantastisch.", + "is_truncated": false, + "reviewer_name": "MaxBook", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWgD2WQYDLnNx9y9xeL6c17ltIL4fgQN70Faim797OjJZfTvIXN=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "3 jaar geleden", + "text_full": "Uitzicht inde kantine fantastisch\nMaar museum zou ik het niet willen noemen\n8 film en foto apparaten en dat is al\nWaardeloos je verwacht grote oude bioscoop projectoren\nDe winkel is groter als het museum", + "is_truncated": false, + "reviewer_name": "JAN DOETJES", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUYjUQIWahnlIth5Bfxe_AO8DMhBJCdFUsT46w3Fy-4uSlIwTg=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Kleine kaart, maar goed eten, zeker voor die prijs. Voorgerechten kunnen echt kleinere porties wat mij betreft", + "is_truncated": false, + "reviewer_name": "Mira Roodenburg", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVAsXJsLZiih1fHnNiD0oZxWp3DAfAS5mOjr0BnEbA9Ygoy-Vnh=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Dit museum staat op een unieke lokatie in Amsterdam Noord. En werkelijk pal aan het ij. Van hieruit kijk je naar de overkant van Amsterdam. Richting Centraal station. He meseum zelf was helaas, vanwege Corona, vandaag gesloten. Maar ga als het weer open is er zeker weer naar toe om het van binnen te bekijken.", + "is_truncated": false, + "reviewer_name": "Rob Bakkenes", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocIPf14ncUawu0Z_xuRxE-eZsJZRzyRamb0YBrbFqBQEsDJBdQ=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "6 jaar geleden", + "text_full": "Eye Filmmuseum is 'n prachtige en 'n hele intrecante museum, voor jong en oud en er zit prachtige ketering met 'n mooie terras.\nWaardoor je mooie uitzicht hebt over het water (zoals het aanzien van Amsterdam centraal station)\nIk geef deze locatie een dikke 9", + "is_truncated": false, + "reviewer_name": "Alfons van Kempen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKqBItH-Irot5RS9OvJwKyFKajArsld-wYV9Q9M8wrxIpKq7w=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Vreemd, maar natuurlijk ook heel bijzonder bouwwerk. Minder ruim dan ik dacht. Het lijkt zo giga, maar duidelijk minder groot dan ik vermoedde... Maar ook is een bezoek aan dit gebouw een belevenis!", + "is_truncated": false, + "reviewer_name": "Arie Bravenboer", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXtd8aq9mJ7rDehjkuOPEWocC0QWNCGtuZWO9c4wJ73hV1XJA=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: een jaar geleden", + "text_full": "Prachtige architectuur, goede exposities en een fijn filmprogramma. Een van de beste zalen in Amsterdam om met je Cineville pas met een goede projectie en geluid films te kijken.", + "is_truncated": false, + "reviewer_name": "Luuk Hoekx", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWXFUOy3KsrkhTmMy2BYdPcA_y33sx_joo_eDivfQDZRsIrEED6=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "8 jaar geleden", + "text_full": "Slechtste museum ter wereld. Als je in slaap wilt vallen en opgesloten wilt zijn in een bioscoopzaal waar je uit wilt, moet je vooral hierheen gaan! Prijs kwaliteit verhouding is heel slecht en personeel doet heel ongeïnteresseerd!", + "is_truncated": false, + "reviewer_name": "Pharaoh Music", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXidhb08dxmwJgT_UQ_2Idz4iXTtqsCEE59-YGEIt8jdWM1ebY=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "5 jaar geleden", + "text_full": "Altijd heerlijk om naar het EYE te gaan. Lekker eten en daarna naar de film. Maakt niet uit of het in de middag of in de avond is. Prachtig uitzicht over het Ij. Moderne en grote bioscoopzalen zonder commerciële rotzooi te draaien.\nHet enige jammere is dat het restaurant ontzettend duur is. Het eten is wel erg lekker. Je betaalt voor de locatie.", + "is_truncated": false, + "reviewer_name": "Julia Florence", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVfPe7lObx90fgVTBMt-Jx_CG1JARt7_5t71LcPfwpIG2riU3Ix=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Verdeeld in twee secties: bovenaan wordt een expositie gedaan rond een bepaald figuur (bekend of onbekend) uit de filmwereld. Onderaan vindt men een historiek van filmtechnologie en -technieken.", + "is_truncated": false, + "reviewer_name": "Olivier Vermeulen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXeosPAPlFPp4pCBMym79Hp9s0Y5xzY5S0NCa2JcVbGO706nU-6=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "2 jaar geleden", + "text_full": "Beetje lang wachten voordat de plaats aangewezen werd,genoeg personeel maar onvoldoende aandacht voor nieuwe gasten.\nKlein tafeltje voor 3 personen.", + "is_truncated": false, + "reviewer_name": "John Houweling", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocKRDoatPU0K8ONR1dqj-blELRWX4akXnw08ywTn0TxbPYYtxA=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "2 jaar geleden", + "text_full": "Mooie lokatie, aardige bediening, eten lekker, maar toch wel wat prijzig voor een kleine salade. Wijn heerlijk.", + "is_truncated": false, + "reviewer_name": "carla denneman", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocK_5cH2ZhTI4WMR6JpqptVsmDJE1RpJSneJLX5jqVIZhqFHcgQ=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "7 jaar geleden", + "text_full": "Ben toch niet dol op Eye. Het is altijd koud in de bioscoop, je kunt daarvoor of daarna niet zo maar zitten om wat te drinken (wachten om een tafel te krijgen en wachttijden te lang) en als je ergens zit moet je hopen dat je daar ook niet op de tocht zit. Wel op een fantastisch plekje in A'dam!", + "is_truncated": false, + "reviewer_name": "Micaela Lomas", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLCcfgWg1R81LzFfy_J89O-ATC-CpD1VLoRjsKrtEfkam4n9g=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "3 jaar geleden", + "text_full": "De expositie vonden we wat tegenvallen.\nWel een mooi gebouw, met mooi uitzicht.\nEr is ook mogelijkheid om films te bekijken.\nRolstoeltoegankelijk.", + "is_truncated": false, + "reviewer_name": "A D", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocI27_ugvRkxTyxm3bgIOMbkaw_3ZogSisNd3cRKeE5DpCM8ng=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "2 jaar geleden", + "text_full": "Fantastische plek om film te ervaren. Niet alleen de kwaliteit van de films en het gebouw, maar ook het publiek wat Eye aantrekt draagt bij aan je bezoek.", + "is_truncated": false, + "reviewer_name": "Reinout Weebers", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV3U7JG7RY-7Wnbg8LvlgbfhEWUq21LIivROuBW6Doxi6Ab-iQN=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 8 jaar geleden", + "text_full": "Prachtige locatie. We hebben hier 2001: a space odyssey gezien in 70 mm met DTS geluid. De film en de muziek was prachtig. Hey EYE is een van de weinig plekken in Europa waar het mogelijk is om het klassieke formaat 70 mm te kijken. Aanrader voor elke filmliefhebber. Prachtige locatie ook!", + "is_truncated": false, + "reviewer_name": "Rien Leuvenink", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjW1u1NZDjkgiuZvHGnhxkYTmeWgI6u7lyfyYtNJHRIz_nc8lKbQeg=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "6 jaar geleden", + "text_full": "Tegenvallend, een kleine permanente expositie, met een beetje interactie. De andere expositie van een geflipte kunstenaar met rare ideeën. Ik snap dat jongeren tot 18 gratis naar binnen mogen, anders krijgen ze alleen maar quasi kunstkenners...", + "is_truncated": false, + "reviewer_name": "Robert van Montfoort", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXjPeV2-YRrVseMsga3OpS1zbbD4Bk5fs3bqXamCvbZxOWosBcM=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "2 jaar geleden", + "text_full": "2 min punten, geen popcorn en geen zitplaatsten vooruit kunnen kiezen. Waardoor je dus soms niet bij elkaar kunt zitten.", + "is_truncated": false, + "reviewer_name": "Raymond Westerbeek", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXSDAUkhWlMmHexUUERfOA6nCDwjpjAN1iDHeL-D1NrOB_uxYt0=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "2 jaar geleden", + "text_full": "Wilde Eye als noorderling het pand bezoeken kort na de opening. Bleek niet met cashgeld te kunnen betalen. Werd op een onbeschofte manier weggestuurd. Ben nooit meer geweest.", + "is_truncated": false, + "reviewer_name": "Patatteke1", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWtR_av4nTqoyShrYW7F4lOxGCWimQim1whkkaacBbHgn8kfhY=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "6 jaar geleden", + "text_full": "Vandaag. Maar 3 sterren. Rijen van 10 wachtenden. Ik adviseer Jumbo het bordje boven de kassa weg te halen waarop staat dat de boodschappen gratis zijn bij een rij van meer dan 4 wachtenden. Het is een uitspraak die jumbo niet wil of kan waarmaken. Geen personeel aanwezig dat optreedt of kan uitleggen wat de spelregels zijn. Voelt aan als klant voor de gek houden .\nVooralsnog betaal ik liever dan voor de gek gehouden worden. Verder stapelen de mandjes zich op bij de kassa en al met al chaos. En nog iets: de reclame van jumbo dat zij de goedkoopste zijn is een leugen.\nOm deze reden ga ik deze winkel voorlopig niet bezoeken.", + "is_truncated": false, + "reviewer_name": "Marnix van Hesteren", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWYoAVxpYB-dUIjGku_Saa1-Qs3lUyr4ooN8jUaIH2SUIxCwyw=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "2 jaar geleden", + "text_full": "Het heet museum, maar daar is dan ook alles mee gezegd. Inhoudelijk heeft het niets met een museum te maken behalve dat er filmhuisfilms vertoond worden tegen extra betaling. Een oudheidkamer met moderne technieken zeg maar...", + "is_truncated": false, + "reviewer_name": "Marco Barelds", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjU_8uAAY6yljPq2lDjo7ONodrj9RIkg80ZmzVmngR8RF3CIbFtJ=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "7 jaar geleden", + "text_full": "Heel mooi, zeker een aanrader! Het restaurant is wel redelijk duur, maar daarvoor heb je een fantastisch uitzicht en lekker eten en drinken. Zelfs als echte Amsterdammer is dit een beleving!", + "is_truncated": false, + "reviewer_name": "Amber van Vliet", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjU6SVg7FPA8bwQnn-KjoZ3KA-vhSN7GhStoLqqFO4PK2unu5KM=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "een jaar geleden", + "text_full": "Ik vond het tegenvallen maar wellicht had ik meer over de historie verwacht dan de ene kleine ruimte die er nu is.", + "is_truncated": false, + "reviewer_name": "Ferry vd B", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocICuZ5gyK2bYEU2M2mc_pbuvnO4OwCIn8X2IGu6C_iRu6ez2w=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "9 jaar geleden", + "text_full": "Visueel aantrekkelijk gebouw, met museum gewijd aan de cinematografie, dat op imponerende wijze uitkijkt over het Ij en daar tegenover het Centraal Station ligt. Vlot bereikbaar via de veerdiensten over het Ij.\nBijzonder aantrekkelijk voor een bezoekje met kinderen, die spelenderwijs de verschillende aspecten van cinema leren ontdekken. Aanpalende gift-shop met cinema/film als centraal thema. Eveneens aanpalend is het EYE bar/restaurant, eveneens gebouwd vanuit het cinema-concept waarbij de gigantische glaspartij als een filmscherm zicht geeft op het Ij en de Amsterdamse stationsbuurt. Vlotte bediening, eten is mogelijk, maar reservatie is aan te raden.", + "is_truncated": false, + "reviewer_name": "Guy Decaluwe", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXg3qvFEA8_KRkr97PY5niB7JsOSJt9kq6KY214LjTCQYTZG4Y-9w=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "5 jaar geleden", + "text_full": "Een must see als je in Noord bent.\nIn het restaurant heb je prachtig uitzicht over het IJ. De bediening is goed. De filmzalen zijn goed. Ze draaien ook kleinere films. Zowel leuk voor een date of om je peuter mee te nemen.", + "is_truncated": false, + "reviewer_name": "Annamaria S", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocJCRzHi4kKEYAsTOfegMrxidh6HLLJ74wEad1mnWX7x5-Ci2Q=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 2, + "relative_time": "6 jaar geleden", + "text_full": "De permanente tentoonstelling is een lachertje en is geen zak aan. Had er echt veel meer van verwacht. De kinderen waren teleurgesteld. De website doet anders overkomen.\nHet is gewoon een veredelde bioscoop, meer niet.", + "is_truncated": false, + "reviewer_name": "Joris de Coo", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjX0G0TPI2XPShjUtaqBQpr7OgrQ_YhGwdFTRNCxsn0IzoXMinI=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "Bewerkt: een jaar geleden", + "text_full": "Een 'open' en leuk filmmuseum. Mijn dochter van 3 was vooral geïnteresseerd in de 'doe' dingen.", + "is_truncated": false, + "reviewer_name": "Eska Doup", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocK14dqO9oPkKW4Pv2LqtIfaZ7DTQW8AYtLa4FPBz3uKDXiSjQ=w36-h36-p-rp-mo-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 3 maanden geleden", + "text_full": "Geweldig uitzicht over het ij", + "is_truncated": true, + "reviewer_name": "Jordy Stravers", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLvMitd_PODhswNydYxi6VA6FOJkIIP1oUYdvIwo9wQ6QqhvQ=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "Bewerkt: 2 jaar geleden", + "text_full": "Prachtig gebouw zowel buiten als binnen. Uitstekend eten voor redelijk prijs.Film programma dik in orde!", + "is_truncated": true, + "reviewer_name": "Roel Wittebrood", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjX-wh-UZWItuWXIFqTmJ3oQmWehQawC3YvuGw4Gv71abIUi9B3_=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "2 jaar geleden", + "text_full": "Hier alleen gegeten en niet naar de film geweest. Prima eten en ontzettend goeie bediening door een jonge gozer die attent was en veel wist te vertellen over de gerechten.", + "is_truncated": true, + "reviewer_name": "Jacques Sjouwerman", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjUPT1eWJJYM6SkUtm-727Alv-faOzffRisBHdSS6JILULdj9py4=w36-h36-p-rp-mo-ba6-br100", + "images": null + }, + { + "rating": 1, + "relative_time": "2 jaar geleden", + "text_full": "Pakt zonder toestemming de TikTok filmpjes erbij van de originele maker van de Werner Herzog Sad Beige serie, en zet dan ook gewoon producten online. Mevrouw probeert al een paar weken contact met ze te krijgen zonder succes.", + "is_truncated": true, + "reviewer_name": "nancy van den akker", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjXK_6JM9s-d0YvXO5P8eVIG7As5mlmcnD0WNC8ku2OljOG3LDw=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "Bewerkt: 9 jaar geleden", + "text_full": "Mooi, groot film en conferentie gebouw. Vanuit het restaurant gedeelte goed uitzicht over het water en Amsterdam. Redelijk makkelijk te bereiken via de pond die elke paar minuten van achter Amsterdam Centraal vertrekt.", + "is_truncated": false, + "reviewer_name": "Joey Simsen", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjV3G7f4vZvC2UJx0KVga4UDaiX4r6lvaUvTYQE_quFkVZ6WBoZ3=w36-h36-p-rp-mo-ba5-br100", + "images": null + }, + { + "rating": 3, + "relative_time": "2 jaar geleden", + "text_full": "De permanente tentoonstelling is interessant maar zeer beperkt. Er is ook een tijdelijke tentoonstelling die voor mij te abstract was. Verder kan je er alternatieve films kijken.", + "is_truncated": true, + "reviewer_name": "kristian groot", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLjXfHkeokiDHxh59OLAD88B3UhFVlDQ8jjdc2_TZvkWO6S0w=w36-h36-p-rp-mo-ba4-br100", + "images": null + }, + { + "rating": 5, + "relative_time": "een jaar geleden", + "text_full": "Geweldige plek om goede films te zien. Noord is best leuk.", + "is_truncated": true, + "reviewer_name": "Bartje Meijer Tekst&Concept", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjVLXIoU2C2mnvBlO8fJvjjEKS2-Fr544yK643ORhZDuXTR3nrs=w36-h36-p-rp-mo-ba3-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "3 jaar geleden", + "text_full": "Restaurant is goed: snelle bediening als je naar de film moet. Eten is gemiddeld. Maar je komt natuurlijk voor de culturele activiteiten die EYE aanbiedt.", + "is_truncated": true, + "reviewer_name": "Masha", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjWq6Tae2GAkPT6j_wx2jIQ0oBH7ZVo4qY9NWe2scpM_BD_2NJjcrA=w36-h36-p-rp-mo-ba2-br100", + "images": null + }, + { + "rating": 4, + "relative_time": "4 jaar geleden", + "text_full": "Eerst gegeten en daarna film gekeken. Restaurant: leuke omgeving, mooi uitzicht over het IJ, vriendelijke bediening, lekker eten, schone toiletten. Film: mooie locatie, ruime stoelen, goed geluid.", + "is_truncated": false, + "reviewer_name": "Rene Koops", + "reviewer_profile_url": null, + "reviewer_avatar_url": "https://lh3.googleusercontent.com/a-/ALV-UjU3zvXD0sgfOJzsSPdX-ZkKEXlGG1upaNPFTL-XTkU2AqzlLhSABg=w36-h36-p-rp-mo-ba6-br100", + "images": null } ], - "reviews_capture_note": "Initial capture of 10 visible reviews. text_full is null because 'See more' buttons were not expanded. Future enhancement needed to capture full review text and scroll for 100+ reviews." -} + "reviews_capture_note": "268 reviews captured via Playwright browser automation on 2025-12-09. 50 representative reviews saved to file. Reviews include rating (1-5), relative_time, text_full, and is_truncated flag. First ~18 reviews have fully expanded text (See more clicked), remaining have text as displayed.", + "total_reviews_captured": 268, + "reviews_saved": 50, + "reviews_updated": "2025-12-09T14:21:27.460734+00:00", + "review_capture_stats": { + "total_captured": 310, + "truncated_count": 7, + "with_images_count": 86 + } +} \ No newline at end of file diff --git a/data/wikidata/GLAMORCUBEPSXHFN/hyponyms_curated.yaml b/data/wikidata/GLAMORCUBEPSXHFN/hyponyms_curated.yaml index 1b51f8ccda..3d91335c54 100644 --- a/data/wikidata/GLAMORCUBEPSXHFN/hyponyms_curated.yaml +++ b/data/wikidata/GLAMORCUBEPSXHFN/hyponyms_curated.yaml @@ -14921,6 +14921,7 @@ hypernym: rico: - label: recordSetTypes - label: Q112796578 + class: True hypernym: - archive type: @@ -15176,6 +15177,7 @@ hypernym: rico: - label: recordSetTypes - label: Q3621648 + class: True hypernym: - archive type: @@ -15281,6 +15283,7 @@ hypernym: type: - A - label: Q9854379 + class: True country: - Portugal hypernym: @@ -15714,6 +15717,7 @@ hypernym: rico: - label: recordSetTypes - label: Q11906844 + class: True hypernym: - archive type: @@ -15845,6 +15849,7 @@ hypernym: type: - D - label: Q5177943 + class: True hypernym: - archive type: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9b39ffc627..419c3c010a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@types/dagre": "^0.7.53", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.20", + "@types/three": "^0.181.0", "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chevrotain-allstar": "^0.3.1", @@ -34,7 +35,7 @@ "lodash": "^4.17.21", "lucide-react": "^0.554.0", "maplibre-gl": "^5.14.0", - "mermaid": "^11.12.1", + "mermaid": "^11.12.2", "n3": "^1.26.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -43,6 +44,8 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "three": "^0.181.2", + "umap-js": "^1.4.0", "zustand": "^5.0.8" }, "devDependencies": { @@ -605,9 +608,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.38.8", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", - "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", + "version": "6.39.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.1.tgz", + "integrity": "sha512-yxpbDf9JwUgLVuAzOS1r0upM+f482FCYkcc+ZbJ34SGBppKL26giehibMEX+nAzLonlrJYiFi9zrftGDrO4mrQ==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", @@ -712,9 +715,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.20.tgz", - "integrity": "sha512-8BHsjXfSciZxjmHQOuVdW2b8WLUPts9a+mfL13/PzEviufUEW2xnvQuOlKs9dRBHgRqJ53SF/DUoK9+MZk72oQ==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", + "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", "dev": true, "funding": [ { @@ -729,6 +732,9 @@ "license": "MIT-0", "engines": { "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" } }, "node_modules/@csstools/css-tokenizer": { @@ -751,6 +757,12 @@ "node": ">=18" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@duckdb/duckdb-wasm": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@duckdb/duckdb-wasm/-/duckdb-wasm-1.30.0.tgz", @@ -1692,9 +1704,9 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.4.tgz", - "integrity": "sha512-LHL17Mq0OcFXm1pGQssuGTQFPPdxARjKM8f7GA5+sGtHi0K3R84YaSbmche0+RKWHnCsx9asEe5OWOI4FHfe4A==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz", + "integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==", "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -2098,9 +2110,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", "dev": true, "license": "MIT" }, @@ -2544,6 +2556,12 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2979,9 +2997,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "version": "24.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", + "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==", "dev": true, "license": "MIT", "dependencies": { @@ -3028,6 +3046,12 @@ "@types/react": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, "node_modules/@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -3037,6 +3061,21 @@ "@types/geojson": "*" } }, + "node_modules/@types/three": { + "version": "0.181.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz", + "integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3050,19 +3089,24 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", - "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/type-utils": "8.48.1", - "@typescript-eslint/utils": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -3075,22 +3119,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.48.1", + "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", - "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "engines": { @@ -3106,14 +3150,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", - "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.1", - "@typescript-eslint/types": "^8.48.1", + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", "debug": "^4.3.4" }, "engines": { @@ -3128,14 +3172,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", - "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1" + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3146,9 +3190,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", - "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", "dev": true, "license": "MIT", "engines": { @@ -3163,15 +3207,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", - "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -3188,9 +3232,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", - "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", "dev": true, "license": "MIT", "engines": { @@ -3202,16 +3246,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", - "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.48.1", - "@typescript-eslint/tsconfig-utils": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4", "minimatch": "^9.0.4", "semver": "^7.6.0", @@ -3230,16 +3274,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", - "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1" + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3254,13 +3298,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.48.1", + "@typescript-eslint/types": "8.49.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -3344,16 +3388,16 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", - "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.47", + "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -3497,6 +3541,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webgpu/types": { + "version": "0.1.67", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.67.tgz", + "integrity": "sha512-uk53+2ECGUkWoDFez/hymwpRfdgdIn6y1ref70fEecGMe5607f4sozNFgBk0oxlr7j2CRGWBEc3IBYMmFdGGTQ==", + "license": "BSD-3-Clause" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -3605,9 +3655,9 @@ } }, "node_modules/apache-arrow/node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "version": "20.19.26", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", + "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3724,9 +3774,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", - "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", + "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3834,9 +3884,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001759", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", - "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", "dev": true, "funding": [ { @@ -4201,14 +4251,14 @@ "license": "MIT" }, "node_modules/cssstyle": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", - "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.4.tgz", + "integrity": "sha512-KyOS/kJMEq5O9GdPnaf82noigg5X5DYn0kZPJTaAsCUaBizp6Xa1y9D4Qoqf/JazEXWuruErHgVXwjN5391ZJw==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^4.0.3", - "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "@asamuzakjp/css-color": "^4.1.0", + "@csstools/css-syntax-patches-for-csstree": "1.0.14", "css-tree": "^3.1.0" }, "engines": { @@ -4863,9 +4913,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", - "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -4892,9 +4942,9 @@ "license": "ISC" }, "node_modules/electron-to-chromium": { - "version": "1.5.266", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", - "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -5371,9 +5421,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5435,7 +5485,6 @@ "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -5682,13 +5731,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/graphlib": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", @@ -6123,6 +6165,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-any-array": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-0.1.1.tgz", + "integrity": "sha512-qTiELO+kpTKqPgxPYbshMERlzaFu29JDnpB8s3bjg+JkxBpw29/qqSaOdKv2pCdaG92rLGeG/zG2GauX58hfoA==", + "license": "MIT" + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6232,15 +6280,15 @@ } }, "node_modules/jsdom": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", - "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.23", - "@asamuzakjp/dom-selector": "^6.7.4", - "cssstyle": "^5.3.3", + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", "data-urls": "^6.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", @@ -6338,9 +6386,9 @@ } }, "node_modules/katex": { - "version": "0.16.26", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.26.tgz", - "integrity": "sha512-LvYwQDwfcFB3rCkxwzqVFxhIB21x1JivrWAs3HT9NsmtgvQrcVCZ6xihnNwXwiQ8UhqRtDJRmwrRz5EgzQ2DuA==", + "version": "0.16.27", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", + "integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -6920,6 +6968,12 @@ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -7539,6 +7593,79 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ml-array-max": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-1.2.4.tgz", + "integrity": "sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ==", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.0" + } + }, + "node_modules/ml-array-max/node_modules/is-any-array": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", + "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", + "license": "MIT" + }, + "node_modules/ml-array-min": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-1.2.3.tgz", + "integrity": "sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q==", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.0" + } + }, + "node_modules/ml-array-min/node_modules/is-any-array": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", + "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", + "license": "MIT" + }, + "node_modules/ml-array-rescale": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-1.3.7.tgz", + "integrity": "sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ==", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.0", + "ml-array-max": "^1.2.4", + "ml-array-min": "^1.2.3" + } + }, + "node_modules/ml-array-rescale/node_modules/is-any-array": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", + "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", + "license": "MIT" + }, + "node_modules/ml-levenberg-marquardt": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ml-levenberg-marquardt/-/ml-levenberg-marquardt-2.1.1.tgz", + "integrity": "sha512-2+HwUqew4qFFFYujYlQtmFUrxCB4iJAPqnUYro3P831wj70eJZcANwcRaIMGUVaH9NDKzfYuA4N5u67KExmaRA==", + "license": "MIT", + "dependencies": { + "is-any-array": "^0.1.0", + "ml-matrix": "^6.4.1" + } + }, + "node_modules/ml-matrix": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.12.1.tgz", + "integrity": "sha512-TJ+8eOFdp+INvzR4zAuwBQJznDUfktMtOB6g/hUcGh3rcyjxbz4Te57Pgri8Q9bhSQ7Zys4IYOGhFdnlgeB6Lw==", + "license": "MIT", + "dependencies": { + "is-any-array": "^2.0.1", + "ml-array-rescale": "^1.3.7" + } + }, + "node_modules/ml-matrix/node_modules/is-any-array": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", + "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -8742,6 +8869,12 @@ "node": ">=12.17" } }, + "node_modules/three": { + "version": "0.181.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz", + "integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -8923,16 +9056,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", - "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", + "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.1", - "@typescript-eslint/parser": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1" + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8961,6 +9094,15 @@ "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", "license": "MIT" }, + "node_modules/umap-js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/umap-js/-/umap-js-1.4.0.tgz", + "integrity": "sha512-xxpviF9wUO6Nxrx+C58SoDgea+h2PnVaRPKDelWv0HotmY6BeWeh0kAPJoumfqUkzUvowGsYfMbnsWI0b9do+A==", + "license": "MIT", + "dependencies": { + "ml-levenberg-marquardt": "^2.0.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -9152,9 +9294,9 @@ } }, "node_modules/vite": { - "version": "7.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", - "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 799e951ddf..deb762c5da 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@types/dagre": "^0.7.53", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.20", + "@types/three": "^0.181.0", "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chevrotain-allstar": "^0.3.1", @@ -42,7 +43,7 @@ "lodash": "^4.17.21", "lucide-react": "^0.554.0", "maplibre-gl": "^5.14.0", - "mermaid": "^11.12.1", + "mermaid": "^11.12.2", "n3": "^1.26.0", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -51,6 +52,8 @@ "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", + "three": "^0.181.2", + "umap-js": "^1.4.0", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/frontend/public/schemas/20251121/linkml/01_custodian_name_modular.yaml b/frontend/public/schemas/20251121/linkml/01_custodian_name_modular.yaml index 3f7e76df4b..48d3ebc1ec 100644 --- a/frontend/public/schemas/20251121/linkml/01_custodian_name_modular.yaml +++ b/frontend/public/schemas/20251121/linkml/01_custodian_name_modular.yaml @@ -185,7 +185,7 @@ imports: - modules/enums/ReconstructionActivityTypeEnum - modules/enums/SourceDocumentTypeEnum # StaffRoleTypeEnum REMOVED - replaced by StaffRole class hierarchy - # See: .opencode/ENUM_TO_CLASS_PRINCIPLE.md for rationale + # See: rules/ENUM_TO_CLASS_PRINCIPLE.md for rationale - modules/enums/CallForApplicationStatusEnum - modules/enums/FundingRequirementTypeEnum @@ -242,7 +242,7 @@ imports: - modules/classes/PersonObservation # Staff role class hierarchy (replaces StaffRoleTypeEnum - Single Source of Truth) - # See: .opencode/ENUM_TO_CLASS_PRINCIPLE.md + # See: rules/ENUM_TO_CLASS_PRINCIPLE.md - modules/classes/StaffRole - modules/classes/StaffRoles diff --git a/frontend/public/schemas/20251121/linkml/manifest.json b/frontend/public/schemas/20251121/linkml/manifest.json index 89c25bcc38..39b62c82f7 100644 --- a/frontend/public/schemas/20251121/linkml/manifest.json +++ b/frontend/public/schemas/20251121/linkml/manifest.json @@ -1,5 +1,5 @@ { - "generated": "2025-12-09T10:49:54.625Z", + "generated": "2025-12-09T15:58:27.582Z", "version": "1.0.0", "categories": [ { diff --git a/frontend/public/schemas/20251121/linkml/modules/classes/CompanyArchives.yaml b/frontend/public/schemas/20251121/linkml/modules/classes/CompanyArchives.yaml index 86c39f2bd3..82e349d773 100644 --- a/frontend/public/schemas/20251121/linkml/modules/classes/CompanyArchives.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/classes/CompanyArchives.yaml @@ -4,11 +4,16 @@ title: Company Archives Type prefixes: linkml: https://w3id.org/linkml/ + schema: http://schema.org/ + org: http://www.w3.org/ns/org# + rico: https://www.ica.org/standards/RiC/ontology# imports: - linkml:types - ./ArchiveOrganizationType - ./CollectionType + - ./Department + - ./OrganizationBranch classes: CompanyArchives: @@ -32,6 +37,37 @@ classes: - Technical drawings and blueprints - Corporate publications + **Organizational Context**: + Company archives are typically organized as: + + 1. **Departments within corporations** (`org:OrganizationalUnit`): + - Archive department under Records Management division + - Historical archives team within Communications/PR + - Technical archives under Engineering department + + 2. **Branches at corporate facilities** (`org:OrganizationalUnit`): + - Central archive at headquarters + - Regional archive at manufacturing sites + - Research archive at R&D centers + + 3. **Standalone heritage organizations** (rare): + - Independent foundation managing corporate heritage + - Heritage society for defunct companies + + **Relationship to Parent Organization**: + + | Pattern | Property | Example | + |---------|----------|---------| + | Archive as department | `schema:department` / `org:hasUnit` | Philips Archive is department of Philips N.V. | + | Archive as branch | `org:hasSite` / `org:unitOf` | Shell Archive at The Hague HQ | + | Archive with parent org | `schema:parentOrganization` | Unilever Historical Archives → Unilever PLC | + + **W3C ORG / Schema.org Alignment**: + - `schema:parentOrganization` - Links archive to the corporation it belongs to + - `schema:department` - Corporation links to its archive department + - `org:unitOf` - Archive is organizational unit of corporation + - `org:hasUnit` - Corporation has archive as organizational unit + **Business Value**: Company archives support: - Legal and regulatory compliance @@ -45,6 +81,8 @@ classes: - BankArchive (Q52718263) - Financial institution archives - EconomicArchive (Q27032167) - Economic history focus - InstitutionalArchive (Q124762372) - Institutional records + - Department - Formal departmental structure within organization + - OrganizationBranch - Physical branch locations of archive **Professional Body**: Company archivists often belong to: @@ -58,7 +96,8 @@ classes: **Ontological Alignment**: - **SKOS**: skos:Concept with skos:broader Q166118 (archive) - - **Schema.org**: schema:ArchiveOrganization + - **Schema.org**: schema:ArchiveOrganization, schema:parentOrganization + - **W3C ORG**: org:OrganizationalUnit, org:unitOf, org:hasUnit - **RiC-O**: rico:CorporateBody (as agent) **Multilingual Labels**: @@ -66,6 +105,11 @@ classes: - es: archivo empresarial - fr: archives d'entreprise + slots: + - parent_corporation + - archive_department_of + - archive_branches + slot_usage: primary_type: description: | @@ -90,23 +134,123 @@ classes: description: | Typically includes: governance records, financial records, product documentation, marketing materials, personnel files. + + parent_corporation: + slot_uri: schema:parentOrganization + description: | + The parent corporation that owns/operates this company archive. + + **Schema.org Alignment**: + `schema:parentOrganization` - "The larger organization that this + organization is a subOrganization of, if any." + + **Use Cases**: + - Philips Company Archives → Philips N.V. + - Shell Historical Archive → Shell PLC + - Siemens Corporate Archives → Siemens AG + + Can reference: + - External URI for the parent corporation + - Custodian instance if parent is also modeled as heritage custodian + range: uriorcurie + examples: + - value: "https://www.wikidata.org/entity/Q163292" + description: "Philips N.V. as parent of Philips Archives" + - value: "https://nde.nl/ontology/hc/nl-corporation/shell-plc" + description: "Shell PLC as parent organization" + + archive_department_of: + slot_uri: org:unitOf + description: | + Links this archive to the Department within which it operates. + + **W3C ORG Alignment**: + `org:unitOf` - "Indicates an Organization of which this Unit is a part." + + Many company archives are organized as: + - Sub-unit of Records Management department + - Part of Corporate Communications + - Under Legal/Compliance division + + Links to Department class for formal departmental context. + range: Department + examples: + - value: + department_name: "Records Management Division" + refers_to_custodian: "https://nde.nl/ontology/hc/nl-corporation/philips" + description: "Archive is unit of Records Management" + + archive_branches: + slot_uri: org:hasSubOrganization + description: | + Physical branch locations of this company archive. + + **W3C ORG Alignment**: + `org:hasSubOrganization` - "Represents hierarchical containment of + Organizations or Organizational Units." + + Large corporations may have multiple archive locations: + - Central archive at headquarters + - Regional archives at major facilities + - Research archives at R&D centers + - Product archives at manufacturing sites + + Links to OrganizationBranch class for physical locations. + range: OrganizationBranch + multivalued: true + inlined_as_list: true + examples: + - value: + - branch_name: "Philips Archives - Eindhoven" + branch_type: REGIONAL_OFFICE + - branch_name: "Philips Research Archives - High Tech Campus" + branch_type: RESEARCH_CENTER + description: "Multiple archive branches" exact_mappings: - skos:Concept close_mappings: - schema:ArchiveOrganization - rico:CorporateBody + - org:OrganizationalUnit + related_mappings: + - schema:parentOrganization + - org:unitOf + - org:hasSubOrganization comments: - "Corporate archives preserving business heritage" - "Important for legal compliance and corporate identity" - "Part of dual-class pattern: custodian type + rico:RecordSetType" - "May have restricted access for commercial sensitivity" + - "Typically organized as Department within larger corporation (org:unitOf)" + - "May have multiple branch locations (org:hasSubOrganization)" + - "Links to parent corporation via schema:parentOrganization" see_also: - BankArchive - EconomicArchive - InstitutionalArchive + - Department + - OrganizationBranch + + examples: + - value: + type_id: "https://nde.nl/ontology/hc/type/archive/company/philips" + primary_type: "ARCHIVE" + wikidata_entity: "Q10605195" + type_label: + - "Philips Company Archives@en" + - "Philips Bedrijfsarchief@nl" + parent_corporation: "https://www.wikidata.org/entity/Q163292" + archive_department_of: + department_name: "Corporate Communications & Heritage" + archive_branches: + - branch_name: "Philips Archives - Eindhoven HQ" + branch_type: REGIONAL_OFFICE + - branch_name: "Philips Research Archives" + branch_type: RESEARCH_CENTER + description: "Philips company archives with organizational context" # rico:RecordSetType for collection classification CompanyArchivesRecordSetType: @@ -123,3 +267,56 @@ classes: annotations: wikidata: Q10605195 linked_custodian_type: CompanyArchives + +# Slot definitions for organizational relationships +slots: + parent_corporation: + slot_uri: schema:parentOrganization + description: | + The parent corporation that owns/operates this company archive. + + Schema.org: parentOrganization - "The larger organization that this + organization is a subOrganization of, if any." + + Inverse of schema:subOrganization. + range: uriorcurie + exact_mappings: + - schema:parentOrganization + comments: + - "Links company archive to owning corporation" + - "Use Wikidata Q-number or organizational URI" + + archive_department_of: + slot_uri: org:unitOf + description: | + Links this archive to the Department within which it operates. + + W3C ORG: unitOf - "Indicates an Organization of which this Unit is a part." + + Company archives are often organized as sub-units of: + - Records Management department + - Corporate Communications + - Legal/Compliance division + range: Department + exact_mappings: + - org:unitOf + comments: + - "Links archive to formal department structure" + - "Inverse of org:hasUnit" + + archive_branches: + slot_uri: org:hasSubOrganization + description: | + Physical branch locations of this company archive. + + W3C ORG: hasSubOrganization - "Represents hierarchical containment of + Organizations or Organizational Units." + + Links to OrganizationBranch instances for each physical location. + range: OrganizationBranch + multivalued: true + exact_mappings: + - org:hasSubOrganization + comments: + - "Multiple archive branch locations" + - "Each branch at different corporate facility" diff --git a/frontend/public/schemas/20251121/linkml/modules/classes/Conservatoria.yaml b/frontend/public/schemas/20251121/linkml/modules/classes/Conservatoria.yaml index 1a8dd49fa5..9a97640a06 100644 --- a/frontend/public/schemas/20251121/linkml/modules/classes/Conservatoria.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/classes/Conservatoria.yaml @@ -2,6 +2,9 @@ id: https://nde.nl/ontology/hc/class/Conservatoria name: Conservatoria title: Conservatória Type (Lusophone) +prefixes: + linkml: https://w3id.org/linkml/ + imports: - linkml:types - ./ArchiveOrganizationType @@ -16,7 +19,8 @@ classes: **Wikidata**: Q9854379 - **Geographic Restriction**: Portugal, Brazil, and other Lusophone countries + **Geographic Restriction**: Lusophone countries (PT, BR, AO, MZ, CV, GW, ST, TL) + This constraint is enforced via LinkML `rules` with `postconditions`. **CUSTODIAN-ONLY**: This type does NOT have a corresponding rico:RecordSetType class. Conservatórias are administrative offices with registration functions, @@ -59,6 +63,7 @@ classes: **Multilingual Labels**: - pt: Conservatória + - pt-BR: Cartório de Registro slot_usage: primary_type: @@ -70,10 +75,49 @@ classes: wikidata_entity: description: | - Should be Q9854379 for Conservatórias. + MUST be Q9854379 for Conservatórias. Lusophone civil/property registration offices. pattern: "^Q[0-9]+$" equals_string: "Q9854379" + + applicable_countries: + description: | + **Geographic Restriction**: Lusophone countries only. + + Conservatórias exist in Portuguese-speaking countries: + - PT (Portugal) - Conservatórias do Registo + - BR (Brazil) - Cartórios de Registro + - AO (Angola) - Conservatórias + - MZ (Mozambique) - Conservatórias + - CV (Cape Verde) - Conservatórias + - GW (Guinea-Bissau) - Conservatórias + - ST (São Tomé and Príncipe) - Conservatórias + - TL (Timor-Leste) - Conservatórias (Portuguese legal heritage) + + The `rules` section below enforces this constraint during validation. + multivalued: true + required: true + minimum_cardinality: 1 + + # LinkML rules for geographic constraint validation + rules: + - description: >- + Conservatoria MUST have applicable_countries containing at least one + Lusophone country (PT, BR, AO, MZ, CV, GW, ST, TL). + This is a mandatory geographic restriction for Portuguese-speaking + civil registry and notarial archive offices. + postconditions: + slot_conditions: + applicable_countries: + any_of: + - equals_string: "PT" + - equals_string: "BR" + - equals_string: "AO" + - equals_string: "MZ" + - equals_string: "CV" + - equals_string: "GW" + - equals_string: "ST" + - equals_string: "TL" exact_mappings: - skos:Concept @@ -82,8 +126,10 @@ classes: - rico:CorporateBody comments: + - "Conservatória (pt)" + - "Cartório de Registro (pt-BR)" - "CUSTODIAN-ONLY type: No corresponding rico:RecordSetType class" - - "Geographic restriction: Lusophone countries (Portugal, Brazil, etc.)" + - "Geographic restriction enforced via LinkML rules: Lusophone countries only" - "Government registration office, not traditional archive" - "Essential for genealogical and legal research" diff --git a/frontend/public/schemas/20251121/linkml/modules/classes/CountyRecordOffice.yaml b/frontend/public/schemas/20251121/linkml/modules/classes/CountyRecordOffice.yaml index 0780574718..6a767928ca 100644 --- a/frontend/public/schemas/20251121/linkml/modules/classes/CountyRecordOffice.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/classes/CountyRecordOffice.yaml @@ -2,21 +2,27 @@ id: https://nde.nl/ontology/hc/class/CountyRecordOffice name: CountyRecordOffice title: County Record Office Type +prefixes: + linkml: https://w3id.org/linkml/ + org: http://www.w3.org/ns/org# + imports: - linkml:types - ./ArchiveOrganizationType + - ./OrganizationBranch classes: CountyRecordOffice: is_a: ArchiveOrganizationType class_uri: skos:Concept description: | - Local authority repository in the United Kingdom and similar jurisdictions, - preserving historical records of the county and its communities. + Local authority repository in the United Kingdom, preserving historical + records of the county and its communities. **Wikidata**: Q5177943 - **Geographic Context**: Primarily United Kingdom + **Geographic Restriction**: United Kingdom (GB) only. + This constraint is enforced via LinkML `rules` with `postconditions`. **CUSTODIAN-ONLY**: This type does NOT have a corresponding rico:RecordSetType class. County Record Offices are institutional types, not collection @@ -40,16 +46,25 @@ classes: - Often designated as place of deposit for public records - Increasingly rebranded as "Archives and Local Studies" + In Scotland: + - Similar functions performed by local authority archives + - National Records of Scotland at national level + + In Northern Ireland: + - Public Record Office of Northern Ireland (PRONI) + - Local council archives + **Related Types**: - LocalGovernmentArchive (Q118281267) - Local authority records - MunicipalArchive (Q604177) - City/town archives - LocalHistoryArchive (Q12324798) - Local history focus **Notable Examples**: - - The National Archives (Kew) - National level - London Metropolitan Archives - Oxfordshire History Centre - Lancashire Archives + - West Yorkshire Archive Service + - Surrey History Centre **Ontological Alignment**: - **SKOS**: skos:Concept with skos:broader Q166118 (archive) @@ -57,6 +72,8 @@ classes: - **RiC-O**: rico:CorporateBody (as agent) **Multilingual Labels**: + - en: County Record Office + - en-GB: County Record Office - it: archivio pubblico territoriale slot_usage: @@ -67,7 +84,7 @@ classes: wikidata_entity: description: | - Should be Q5177943 for county record offices. + MUST be Q5177943 for county record offices. UK local authority archive type. pattern: "^Q[0-9]+$" equals_string: "Q5177943" @@ -76,6 +93,66 @@ classes: description: | Typically 'county' or 'local' for this archive type. Corresponds to UK county administrative level. + + is_branch_of_authority: + description: | + **Organizational Relationship**: County Record Offices may be branches + of larger local authority structures. + + **Common Parent Organizations**: + - County Councils (e.g., Oxfordshire County Council) + - Unitary Authorities (e.g., Bristol City Council) + - Combined Authorities (e.g., Greater Manchester) + - Joint Archive Services (e.g., East Sussex / Brighton & Hove) + + **Legal Context**: + County Record Offices are typically: + - Designated "place of deposit" under Public Records Act 1958 + - Part of local authority heritage/cultural services + - May share governance with local studies libraries + + **Use org:unitOf pattern** from OrganizationBranch to link to parent + authority when modeled as formal organizational unit. + + **Examples**: + - Oxfordshire History Centre → part of Oxfordshire County Council + - London Metropolitan Archives → part of City of London Corporation + - West Yorkshire Archive Service → joint service of five councils + range: uriorcurie + multivalued: false + required: false + examples: + - value: "https://nde.nl/ontology/hc/uk/oxfordshire-county-council" + description: "Parent local authority" + + applicable_countries: + description: | + **Geographic Restriction**: United Kingdom (GB) only. + + County Record Offices are a UK-specific institution type within + the local authority structure of England, Wales, Scotland, and + Northern Ireland. + + Note: Uses ISO 3166-1 alpha-2 code "GB" for United Kingdom + (not "UK" which is not a valid ISO code). + + The `rules` section below enforces this constraint during validation. + ifabsent: "string(GB)" + required: true + minimum_cardinality: 1 + maximum_cardinality: 1 + + # LinkML rules for geographic constraint validation + rules: + - description: >- + CountyRecordOffice MUST have applicable_countries containing "GB" + (United Kingdom). This is a mandatory geographic restriction for + UK county record offices and local authority archives. + postconditions: + slot_conditions: + applicable_countries: + any_of: + - equals_string: "GB" exact_mappings: - skos:Concept @@ -84,7 +161,9 @@ classes: - rico:CorporateBody comments: + - "County Record Office (en-GB)" - "CUSTODIAN-ONLY type: No corresponding rico:RecordSetType class" + - "Geographic restriction enforced via LinkML rules: United Kingdom (GB) only" - "UK local authority archive institution type" - "Often designated place of deposit for public records" - "Key resource for local and family history research" @@ -93,3 +172,12 @@ classes: - LocalGovernmentArchive - MunicipalArchive - LocalHistoryArchive + - OrganizationBranch + +slots: + is_branch_of_authority: + slot_uri: org:unitOf + description: | + Parent local authority or governing body for this County Record Office. + Uses W3C Org ontology org:unitOf relationship. + range: uriorcurie diff --git a/frontend/public/schemas/20251121/linkml/modules/classes/CurrentArchive.yaml b/frontend/public/schemas/20251121/linkml/modules/classes/CurrentArchive.yaml index ddd9622993..9c719757ba 100644 --- a/frontend/public/schemas/20251121/linkml/modules/classes/CurrentArchive.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/classes/CurrentArchive.yaml @@ -22,6 +22,7 @@ imports: - linkml:types - ./ArchiveOrganizationType - ./CustodianAdministration + - ./CustodianArchive classes: CurrentArchive: @@ -63,6 +64,24 @@ classes: - HistoricalArchive (Q3621673) - non-current permanent records - RecordsCenter - semi-current storage facility + **RELATIONSHIP TO CustodianArchive**: + + CurrentArchive (this class) is a TYPE classification (skos:Concept) for + archives managing records in the active/current phase of the lifecycle. + + CustodianArchive is an INSTANCE class (rico:RecordSet) representing the + actual operational archives of a heritage custodian awaiting processing. + + **Semantic Relationship**: + - CurrentArchive is a HYPERNYM (broader type) for the concept of active records + - CustodianArchive records MAY be typed as CurrentArchive when in active use + - When CustodianArchive.processing_status = "UNPROCESSED", records may still + be in the current/active phase conceptually + + **SKOS Alignment**: + - skos:broader: CurrentArchive → DepositArchive (lifecycle progression) + - skos:narrower: CurrentArchive ← specific current archive types + **ONTOLOGICAL ALIGNMENT**: - **SKOS**: skos:Concept (type classification) - **RiC-O**: rico:RecordSet for active record groups @@ -74,6 +93,7 @@ classes: - retention_schedule - creating_organization - transfer_policy + - has_narrower_instance slot_usage: wikidata_entity: @@ -101,6 +121,25 @@ classes: Policy for transferring records to intermediate or permanent archives. Describes triggers, timelines, and procedures for transfer. range: string + + has_narrower_instance: + slot_uri: skos:narrowerTransitive + description: | + Links this archive TYPE to specific CustodianArchive INSTANCES + that are classified under this lifecycle phase. + + **SKOS**: skos:narrowerTransitive for type-instance relationship. + + **Usage**: + When a CustodianArchive contains records in the "current/active" phase, + it can be linked from CurrentArchive via this property. + + **Example**: + - CurrentArchive (type) → has_narrower_instance → + CustodianArchive "Director's Active Files 2020-2024" (instance) + range: CustodianArchive + multivalued: true + required: false exact_mappings: - wikidata:Q3621648 @@ -145,3 +184,11 @@ slots: transfer_policy: description: Policy for transferring to permanent archive range: string + + has_narrower_instance: + slot_uri: skos:narrowerTransitive + description: | + Links archive TYPE to specific CustodianArchive INSTANCES. + SKOS narrowerTransitive for type-to-instance relationship. + range: CustodianArchive + multivalued: true diff --git a/frontend/public/schemas/20251121/linkml/modules/classes/CustodianArchive.yaml b/frontend/public/schemas/20251121/linkml/modules/classes/CustodianArchive.yaml index 97ed886f6f..dc5d1cdb69 100644 --- a/frontend/public/schemas/20251121/linkml/modules/classes/CustodianArchive.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/classes/CustodianArchive.yaml @@ -20,6 +20,7 @@ imports: - ../slots/access_restrictions - ../slots/storage_location - ./ReconstructedEntity + - ./CurrentArchive prefixes: linkml: https://w3id.org/linkml/ @@ -31,6 +32,8 @@ prefixes: time: http://www.w3.org/2006/time# org: http://www.w3.org/ns/org# premis: http://www.loc.gov/premis/rdf/v3/ + skos: http://www.w3.org/2004/02/skos/core# + wikidata: http://www.wikidata.org/entity/ classes: CustodianArchive: @@ -122,6 +125,18 @@ classes: - **Storage**: Physical location of unprocessed archives - **OrganizationalStructure**: Unit responsible for processing + **RELATIONSHIP TO LIFECYCLE TYPE CLASSES**: + + CustodianArchive (this class) is an INSTANCE class representing actual + operational archives. It can be TYPED using lifecycle phase classifications: + + - **CurrentArchive** (Q3621648): Active records in daily use + - skos:broaderTransitive links CustodianArchive → CurrentArchive type + - **DepositArchive** (Q244904): Intermediate/semi-current records + - **HistoricalArchive** (Q3621673): Permanent archival records + + Use `lifecycle_phase_type` slot to classify by lifecycle position. + exact_mappings: - rico:RecordSet @@ -162,6 +177,7 @@ classes: - was_generated_by - valid_from - valid_to + - lifecycle_phase_type slot_usage: id: @@ -591,6 +607,33 @@ classes: required: false description: | End of validity period (typically = transfer_to_collection_date). + + lifecycle_phase_type: + slot_uri: skos:broaderTransitive + range: uriorcurie + required: false + description: | + Links this CustodianArchive INSTANCE to its lifecycle phase TYPE. + + **SKOS**: skos:broaderTransitive for instance-to-type relationship. + + **Archive Lifecycle Types (Wikidata)**: + - Q3621648 (CurrentArchive) - Active records phase + - Q244904 (DepositArchive) - Intermediate/semi-current phase + - Q3621673 (HistoricalArchive) - Archival/permanent phase + + **Usage**: + Classify this operational archive by its position in the records lifecycle. + Most CustodianArchive records are in the intermediate phase (awaiting processing). + + **Example**: + - CustodianArchive "Ministry Records 2010-2020" → lifecycle_phase_type → + DepositArchive (Q244904) - semi-current, awaiting processing + examples: + - value: "wikidata:Q244904" + description: "Deposit archive / semi-current records" + - value: "wikidata:Q3621648" + description: "Current archive / active records" comments: - "Represents operational archives BEFORE integration into CustodianCollection" @@ -719,3 +762,12 @@ slots: arrangement_notes: description: Notes from arrangement process range: string + + lifecycle_phase_type: + slot_uri: skos:broaderTransitive + description: | + Links CustodianArchive INSTANCE to lifecycle phase TYPE. + SKOS broaderTransitive for instance-to-type relationship. + Values: CurrentArchive (Q3621648), DepositArchive (Q244904), + HistoricalArchive (Q3621673). + range: uriorcurie diff --git a/frontend/public/schemas/20251121/linkml/modules/classes/CustodianName.yaml b/frontend/public/schemas/20251121/linkml/modules/classes/CustodianName.yaml index 71d17e2c08..2019410f26 100644 --- a/frontend/public/schemas/20251121/linkml/modules/classes/CustodianName.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/classes/CustodianName.yaml @@ -61,7 +61,7 @@ classes: - Portuguese: Fundação, Associação, Ltda., S.A. - Italian: Fondazione, Associazione, S.p.A., S.r.l. - See: .opencode/LEGAL_FORM_FILTERING_RULE.md for comprehensive global list + See: rules/LEGAL_FORM_FILTERING_RULE.md for comprehensive global list =========================================================================== MANDATORY RULE: Special Characters MUST Be Excluded from Abbreviations @@ -112,7 +112,7 @@ classes: - "Heritage@Digital" → "HD" (not "H@D") - "Archives (Historical)" → "AH" (not "A(H)") - See: .opencode/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation + See: rules/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation =========================================================================== MANDATORY RULE: Diacritics MUST Be Normalized to ASCII in Abbreviations @@ -152,7 +152,7 @@ classes: ascii_text = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn') ``` - See: .opencode/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation + See: rules/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation Can be generated by: 1. ReconstructionActivity (formal entity resolution) - was_generated_by link diff --git a/frontend/public/schemas/20251121/linkml/modules/classes/Department.yaml b/frontend/public/schemas/20251121/linkml/modules/classes/Department.yaml index ff24608547..0d4cf48503 100644 --- a/frontend/public/schemas/20251121/linkml/modules/classes/Department.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/classes/Department.yaml @@ -35,6 +35,11 @@ imports: - ./OrganizationalStructure - ./Collection - ./PersonObservation + # Import global slots + - ../slots/staff_members + - ../slots/contact_point + - ../slots/located_at + - ../slots/refers_to_custodian classes: Department: @@ -456,6 +461,20 @@ slots: description: Person heading the department range: PersonObservation + # NOTE: staff_members imported from global slot ../slots/staff_members.yaml + + manages_collections: + slot_uri: rico:isManagerOf + description: Collections managed by this department + range: Collection + multivalued: true + + # NOTE: located_at imported from global slot ../slots/located_at.yaml + + # NOTE: contact_point imported from global slot ../slots/contact_point.yaml + + # NOTE: refers_to_custodian imported from global slot ../slots/refers_to_custodian.yaml + established_date: description: Date department was established range: date diff --git a/frontend/public/schemas/20251121/linkml/modules/classes/WebClaim.yaml b/frontend/public/schemas/20251121/linkml/modules/classes/WebClaim.yaml index 7ab5f61a10..9458360844 100644 --- a/frontend/public/schemas/20251121/linkml/modules/classes/WebClaim.yaml +++ b/frontend/public/schemas/20251121/linkml/modules/classes/WebClaim.yaml @@ -470,7 +470,7 @@ classes: - "Follows 4-stage GLAM-NER pipeline: recognition → layout → resolution → linking" see_also: - - ".opencode/WEB_OBSERVATION_PROVENANCE_RULES.md" + - "rules/WEB_OBSERVATION_PROVENANCE_RULES.md" - "scripts/fetch_website_playwright.py" - "scripts/add_xpath_provenance.py" - "docs/convention/schema/20251202/entity_annotation_rules_v1.6.0_unified.yaml" diff --git a/frontend/public/schemas/20251121/linkml/rules/ABBREVIATION_RULES.md b/frontend/public/schemas/20251121/linkml/rules/ABBREVIATION_RULES.md new file mode 100644 index 0000000000..8bb428ee75 --- /dev/null +++ b/frontend/public/schemas/20251121/linkml/rules/ABBREVIATION_RULES.md @@ -0,0 +1,303 @@ +# Abbreviation Character Filtering Rules + +**Rule ID**: ABBREV-CHAR-FILTER +**Status**: MANDATORY +**Applies To**: GHCID abbreviation component generation +**Created**: 2025-12-07 +**Updated**: 2025-12-08 (added diacritics rule) + +--- + +## Summary + +**When generating abbreviations for GHCID, ONLY ASCII uppercase letters (A-Z) are permitted. Both special characters AND diacritics MUST be removed/normalized.** + +This is a **MANDATORY** rule. Abbreviations containing special characters or diacritics are INVALID and must be regenerated. + +### Two Mandatory Sub-Rules: + +1. **ABBREV-SPECIAL-CHAR**: Remove all special characters and symbols +2. **ABBREV-DIACRITICS**: Normalize all diacritics to ASCII equivalents + +--- + +## Rule 1: Diacritics MUST Be Normalized to ASCII (ABBREV-DIACRITICS) + +**Diacritics (accented characters) MUST be normalized to their ASCII base letter equivalents.** + +### Example (Real Case) + +``` +❌ WRONG: CZ-VY-TEL-L-VHSPAOČRZS (contains Č) +✅ CORRECT: CZ-VY-TEL-L-VHSPAOCRZS (ASCII only) +``` + +### Diacritics Normalization Table + +| Diacritic | ASCII | Example | +|-----------|-------|---------| +| Á, À, Â, Ã, Ä, Å, Ā | A | "Ålborg" → A | +| Č, Ć, Ç | C | "Český" → C | +| Ď | D | "Ďáblice" → D | +| É, È, Ê, Ë, Ě, Ē | E | "Éire" → E | +| Í, Ì, Î, Ï, Ī | I | "Ísland" → I | +| Ñ, Ń, Ň | N | "España" → N | +| Ó, Ò, Ô, Õ, Ö, Ø, Ō | O | "Österreich" → O | +| Ř | R | "Říčany" → R | +| Š, Ś, Ş | S | "Šumperk" → S | +| Ť | T | "Ťažký" → T | +| Ú, Ù, Û, Ü, Ů, Ū | U | "Ústí" → U | +| Ý, Ÿ | Y | "Ýmir" → Y | +| Ž, Ź, Ż | Z | "Žilina" → Z | +| Ł | L | "Łódź" → L | +| Æ | AE | "Ærø" → AE | +| Œ | OE | "Œuvre" → OE | +| ß | SS | "Straße" → SS | + +### Implementation + +```python +import unicodedata + +def normalize_diacritics(text: str) -> str: + """ + Normalize diacritics to ASCII equivalents. + + Examples: + "Č" → "C" + "Ř" → "R" + "Ö" → "O" + "ñ" → "n" + """ + # NFD decomposition separates base characters from combining marks + normalized = unicodedata.normalize('NFD', text) + # Remove combining marks (category 'Mn' = Mark, Nonspacing) + ascii_text = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn') + return ascii_text + +# Example +normalize_diacritics("VHSPAOČRZS") # Returns "VHSPAOCRZS" +``` + +### Languages Commonly Affected + +| Language | Common Diacritics | Example Institution | +|----------|-------------------|---------------------| +| **Czech** | Č, Ř, Š, Ž, Ě, Ů | Vlastivědné muzeum → VM (not VM with háček) | +| **Polish** | Ł, Ń, Ó, Ś, Ź, Ż, Ą, Ę | Biblioteka Łódzka → BL | +| **German** | Ä, Ö, Ü, ß | Österreichische Nationalbibliothek → ON | +| **French** | É, È, Ê, Ç, Ô | Bibliothèque nationale → BN | +| **Spanish** | Ñ, Á, É, Í, Ó, Ú | Museo Nacional → MN | +| **Portuguese** | Ã, Õ, Ç, Á, É | Biblioteca Nacional → BN | +| **Nordic** | Å, Ä, Ö, Ø, Æ | Nationalmuseet → N | +| **Turkish** | Ç, Ğ, İ, Ö, Ş, Ü | İstanbul Üniversitesi → IU | +| **Hungarian** | Á, É, Í, Ó, Ö, Ő, Ú, Ü, Ű | Országos Levéltár → OL | +| **Romanian** | Ă, Â, Î, Ș, Ț | Biblioteca Națională → BN | + +--- + +## Rule 2: Special Characters MUST Be Removed (ABBREV-SPECIAL-CHAR) + +--- + +## Rationale + +### 1. URL/URI Safety +Special characters require percent-encoding in URIs. For example: +- `&` becomes `%26` +- `+` becomes `%2B` + +This makes identifiers harder to share, copy, and verify. + +### 2. Filename Safety +Many special characters are invalid in filenames across operating systems: +- Windows: `\ / : * ? " < > |` +- macOS/Linux: `/` and null bytes + +Files like `SX-XX-PHI-O-DR&IMSM.yaml` may cause issues on some systems. + +### 3. Parsing Consistency +Special characters can conflict with delimiters in data pipelines: +- `&` is used in query strings +- `:` is used in YAML, JSON +- `/` is a path separator +- `|` is a common CSV delimiter alternative + +### 4. Cross-System Compatibility +Identifiers should work across all systems: +- Databases (SQL, TypeDB, Neo4j) +- RDF/SPARQL endpoints +- REST APIs +- Command-line tools +- Spreadsheets + +### 5. Human Readability +Clean identifiers are easier to: +- Communicate verbally +- Type correctly +- Proofread +- Remember + +--- + +## Characters to Remove + +The following characters MUST be completely removed (not replaced) when generating abbreviations: + +| Character | Name | Example Issue | +|-----------|------|---------------| +| `&` | Ampersand | "R&A" in URLs, HTML entities | +| `/` | Slash | Path separator confusion | +| `\` | Backslash | Escape sequence issues | +| `+` | Plus | URL encoding (`+` = space) | +| `@` | At sign | Email/handle confusion | +| `#` | Hash/Pound | Fragment identifier in URLs | +| `%` | Percent | URL encoding prefix | +| `$` | Dollar | Variable prefix in shells | +| `*` | Asterisk | Glob/wildcard character | +| `(` `)` | Parentheses | Grouping in regex, code | +| `[` `]` | Square brackets | Array notation | +| `{` `}` | Curly braces | Object notation | +| `\|` | Pipe | Command chaining, OR operator | +| `:` | Colon | YAML key-value, namespace separator | +| `;` | Semicolon | Statement terminator | +| `"` `'` `` ` `` | Quotes | String delimiters | +| `,` | Comma | List separator | +| `.` | Period | File extension, namespace | +| `-` | Hyphen | Already used as GHCID component separator | +| `_` | Underscore | Reserved for name suffix in collisions | +| `=` | Equals | Assignment operator | +| `?` | Question mark | Query string indicator | +| `!` | Exclamation | Negation, shell history | +| `~` | Tilde | Home directory, bitwise NOT | +| `^` | Caret | Regex anchor, power operator | +| `<` `>` | Angle brackets | HTML tags, redirects | + +--- + +## Implementation + +### Algorithm + +When extracting abbreviation from institution name: + +```python +import re +import unicodedata + +def extract_abbreviation_from_name(name: str, skip_words: set) -> str: + """ + Extract abbreviation from institution name. + + Args: + name: Full institution name (emic) + skip_words: Set of prepositions/articles to skip + + Returns: + Uppercase abbreviation with only A-Z characters + """ + # Step 1: Normalize unicode (remove diacritics) + normalized = unicodedata.normalize('NFD', name) + ascii_name = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn') + + # Step 2: Replace special characters with spaces (to split words) + # This handles cases like "Records&Information" -> "Records Information" + clean_name = re.sub(r'[^a-zA-Z\s]', ' ', ascii_name) + + # Step 3: Split into words + words = clean_name.split() + + # Step 4: Filter out skip words (prepositions, articles) + significant_words = [w for w in words if w.lower() not in skip_words] + + # Step 5: Take first letter of each significant word + abbreviation = ''.join(w[0].upper() for w in significant_words if w) + + # Step 6: Limit to 10 characters + return abbreviation[:10] +``` + +### Handling Special Cases + +**Case 1: "Records & Information Management"** +1. Input: `"Records & Information Management"` +2. After special char removal: `"Records Information Management"` +3. After split: `["Records", "Information", "Management"]` +4. Abbreviation: `RIM` + +**Case 2: "Art/Design Museum"** +1. Input: `"Art/Design Museum"` +2. After special char removal: `"Art Design Museum"` +3. After split: `["Art", "Design", "Museum"]` +4. Abbreviation: `ADM` + +**Case 3: "Culture+"** +1. Input: `"Culture+"` +2. After special char removal: `"Culture"` +3. After split: `["Culture"]` +4. Abbreviation: `C` + +--- + +## Examples + +| Institution Name | Correct | Incorrect | +|------------------|---------|-----------| +| Department of Records & Information Management | DRIM | DR&IM | +| Art + Culture Center | ACC | A+CC | +| Museum/Gallery Amsterdam | MGA | M/GA | +| Heritage@Digital | HD | H@D | +| Archives (Historical) | AH | A(H) | +| Research & Development Institute | RDI | R&DI | +| Sint Maarten Records & Information | SMRI | SMR&I | + +--- + +## Validation + +### Check for Invalid Abbreviations + +```bash +# Find GHCID files with special characters in abbreviation +find data/custodian -name "*.yaml" | xargs grep -l '[&+@#%$*|:;?!=~^<>]' | head -20 + +# Specifically check for & in filenames +find data/custodian -name "*&*.yaml" +``` + +### Programmatic Validation + +```python +import re + +def validate_abbreviation(abbrev: str) -> bool: + """ + Validate that abbreviation contains only A-Z. + + Returns True if valid, False if contains special characters. + """ + return bool(re.match(r'^[A-Z]+$', abbrev)) + +# Examples +validate_abbreviation("DRIMSM") # True - valid +validate_abbreviation("DR&IMSM") # False - contains & +validate_abbreviation("A+CC") # False - contains + +``` + +--- + +## Related Documentation + +- `AGENTS.md` - Section "INSTITUTION ABBREVIATION: EMIC NAME FIRST-LETTER PROTOCOL" +- `schemas/20251121/linkml/modules/classes/CustodianName.yaml` - Schema description +- `rules/LEGAL_FORM_FILTER.md` - Related filtering rule for legal forms +- `docs/PERSISTENT_IDENTIFIERS.md` - GHCID specification + +--- + +## Changelog + +| Date | Change | +|------|--------| +| 2025-12-07 | Initial rule created after discovery of `&` in GHCID | +| 2025-12-08 | Added diacritics normalization rule | diff --git a/frontend/public/schemas/20251121/linkml/rules/ENUM_TO_CLASS.md b/frontend/public/schemas/20251121/linkml/rules/ENUM_TO_CLASS.md new file mode 100644 index 0000000000..c502d45773 --- /dev/null +++ b/frontend/public/schemas/20251121/linkml/rules/ENUM_TO_CLASS.md @@ -0,0 +1,237 @@ +# Enum-to-Class Principle: Single Source of Truth + +**Rule ID**: ENUM-TO-CLASS +**Status**: ACTIVE +**Applies To**: Schema evolution decisions +**Version**: 1.0 +**Last Updated**: 2025-12-06 + +--- + +## Core Principle + +**Enums are TEMPORARY scaffolding. Once an enum is promoted to a class hierarchy, the enum MUST be deleted to maintain a Single Source of Truth.** + +--- + +## Rationale + +### The Problem: Dual Representation + +When both an enum AND a class hierarchy exist for the same concept: +- **Data sync issues**: Enum values and class names can drift apart +- **Maintenance burden**: Changes must be made in two places +- **Developer confusion**: Which one should I use? +- **Validation conflicts**: Enum constraints vs class ranges may diverge + +### The Solution: Single Source of Truth + +- **Enums**: Use for simple, fixed value constraints (e.g., `DataTierEnum: TIER_1, TIER_2, TIER_3, TIER_4`) +- **Classes**: Use when the concept needs properties, relationships, or rich documentation +- **NEVER BOTH**: Once promoted to classes, DELETE the enum + +--- + +## When to Promote Enum to Classes + +**Promote when the concept needs**: + +| Need | Enum Can Do? | Class Required? | +|------|-------------|-----------------| +| Fixed value constraint | Yes | Yes | +| Properties (e.g., `role_category`, `typical_domains`) | No | Yes | +| Rich description per value | Limited | Yes | +| Relationships to other entities | No | Yes | +| Inheritance hierarchy | No | Yes | +| Independent identity (URI) | Limited | Yes | +| Ontology class mapping (`class_uri`) | Via `meaning` | Native | + +**Rule of thumb**: If you're adding detailed documentation to each enum value, or want to attach properties, it's time to promote to classes. + +--- + +## Promotion Workflow + +### Step 1: Create Class Hierarchy + +```yaml +# modules/classes/StaffRole.yaml (base class) +StaffRole: + abstract: true + description: Base class for staff role categories + slots: + - role_id + - role_name + - role_category + - typical_domains + +# modules/classes/StaffRoles.yaml (subclasses) +Curator: + is_a: StaffRole + description: Museum curator specializing in collection research... + +Conservator: + is_a: StaffRole + description: Conservator specializing in preservation... +``` + +### Step 2: Update Slot Ranges + +```yaml +# BEFORE (enum) +staff_role: + range: StaffRoleTypeEnum + +# AFTER (class) +staff_role: + range: StaffRole +``` + +### Step 3: Update Modular Schema Imports + +```yaml +# REMOVE enum import +# - modules/enums/StaffRoleTypeEnum # DELETED + +# ADD class imports +- modules/classes/StaffRole +- modules/classes/StaffRoles +``` + +### Step 4: Archive the Enum + +```bash +mkdir -p schemas/.../archive/enums +mv modules/enums/OldEnum.yaml archive/enums/OldEnum.yaml.archived_$(date +%Y%m%d) +``` + +### Step 5: Document the Change + +- Update `archive/enums/README.md` with migration entry +- Add comment in modular schema explaining removal +- Update any documentation referencing the old enum + +--- + +## Example: StaffRoleTypeEnum to StaffRole + +**Before** (2025-12-05): +```yaml +# StaffRoleTypeEnum.yaml +StaffRoleTypeEnum: + permissible_values: + CURATOR: + description: Museum curator + CONSERVATOR: + description: Conservator + # ... 51 values with limited documentation +``` + +**After** (2025-12-06): +```yaml +# StaffRole.yaml (abstract base) +StaffRole: + abstract: true + slots: + - role_id + - role_name + - role_category + - typical_domains + - typical_responsibilities + - requires_qualification + +# StaffRoles.yaml (51 subclasses) +Curator: + is_a: StaffRole + class_uri: schema:curator + description: | + Museum curator specializing in collection research... + + **IMPORTANT - FORMAL TITLE vs DE FACTO WORK**: + This is the OFFICIAL job appellation/title. Actual work may differ. + slot_usage: + role_category: + equals_string: CURATORIAL + typical_domains: + equals_expression: "[Museums, Galleries]" +``` + +**Why the promotion?** +1. Need to distinguish FORMAL TITLE from DE FACTO WORK +2. Each role has `role_category`, `common_variants`, `typical_domains`, `typical_responsibilities` +3. Roles benefit from inheritance (`Curator is_a StaffRole`) +4. Richer documentation per role + +--- + +## Enums That Should REMAIN Enums + +Some enums are appropriate as permanent fixtures: + +| Enum | Why Keep as Enum | +|------|------------------| +| `DataTierEnum` | Simple 4-value tier (TIER_1 through TIER_4), no properties needed | +| `DataSourceEnum` | Fixed source types, simple strings | +| `CountryCodeEnum` | ISO 3166-1 standard, no custom properties | +| `LanguageCodeEnum` | ISO 639 standard, no custom properties | + +**Characteristics of "permanent" enums**: +- Based on external standards (ISO, etc.) +- Simple values with no need for properties +- Unlikely to require rich per-value documentation +- Used purely for validation/constraint + +--- + +## Anti-Patterns + +### WRONG: Keep Both Enum and Classes + +```yaml +# modules/enums/StaffRoleTypeEnum.yaml # Still exists! +# modules/classes/StaffRole.yaml # Also exists! +# Which one is authoritative? CONFUSION! +``` + +### WRONG: Create Classes but Keep Enum "for backwards compatibility" + +```yaml +# "Let's keep the enum for old code" +# Result: Two sources of truth, guaranteed drift +``` + +### CORRECT: Delete Enum After Creating Classes + +```yaml +# modules/enums/StaffRoleTypeEnum.yaml # ARCHIVED +# modules/classes/StaffRole.yaml # Single source of truth +# modules/classes/StaffRoles.yaml # All 51 role subclasses +``` + +--- + +## Verification Checklist + +After promoting an enum to classes: + +- [ ] Old enum file moved to `archive/enums/` +- [ ] Modular schema import removed for enum +- [ ] Modular schema import added for new class(es) +- [ ] All slot ranges updated from enum to class +- [ ] No grep results for old enum name in active schema files +- [ ] `archive/enums/README.md` updated with migration entry +- [ ] Comment added in modular schema explaining removal + +```bash +# Verify enum is fully removed (should return only archive hits) +grep -r "StaffRoleTypeEnum" schemas/20251121/linkml/ +``` + +--- + +## See Also + +- `docs/ENUM_CLASS_SINGLE_SOURCE.md` - Extended documentation +- `schemas/20251121/linkml/archive/enums/README.md` - Archive directory +- LinkML documentation on enums: https://linkml.io/linkml/schemas/enums.html +- LinkML documentation on classes: https://linkml.io/linkml/schemas/models.html diff --git a/frontend/public/schemas/20251121/linkml/rules/GEONAMES_SETTLEMENT.md b/frontend/public/schemas/20251121/linkml/rules/GEONAMES_SETTLEMENT.md new file mode 100644 index 0000000000..1add177e09 --- /dev/null +++ b/frontend/public/schemas/20251121/linkml/rules/GEONAMES_SETTLEMENT.md @@ -0,0 +1,436 @@ +# GeoNames Settlement Standardization Rules + +**Rule ID**: GEONAMES-SETTLEMENT +**Status**: MANDATORY +**Applies To**: GHCID settlement component generation +**Version**: 1.1.0 +**Effective Date**: 2025-12-01 +**Last Updated**: 2025-12-01 + +--- + +## Purpose + +This document defines the rules for standardizing settlement names in GHCID (Global Heritage Custodian Identifier) generation using the GeoNames geographical database. + +## Core Principle + +**ALL settlement names in GHCID must be derived from GeoNames standardized names, not from source data.** + +The GeoNames database serves as the **single source of truth** for: +- Settlement names (cities, towns, villages) +- Settlement abbreviations/codes +- Administrative region codes (admin1) +- Geographic coordinates validation + +## Why GeoNames Standardization? + +1. **Consistency**: Same settlement = same GHCID component, regardless of source data variations +2. **Disambiguation**: Handles duplicate city names across regions +3. **Internationalization**: Provides ASCII-safe names for identifiers +4. **Authority**: GeoNames is a well-maintained, CC-licensed geographic database +5. **Persistence**: Settlement names don't change frequently, ensuring GHCID stability + +--- + +## CRITICAL: Feature Code Filtering + +**NEVER use neighborhoods or districts (PPLX) for GHCID generation. ONLY use proper settlements (cities, towns, villages).** + +GeoNames classifies populated places with feature codes. When reverse geocoding coordinates to find a settlement, you MUST filter by feature code. + +### ALLOWED Feature Codes + +| Code | Description | Example | +|------|-------------|---------| +| **PPL** | Populated place (city/town/village) | Apeldoorn, Hamont, Lelystad | +| **PPLA** | Seat of first-order admin division | Provincial capitals | +| **PPLA2** | Seat of second-order admin division | Municipal seats | +| **PPLA3** | Seat of third-order admin division | District seats | +| **PPLA4** | Seat of fourth-order admin division | Sub-district seats | +| **PPLC** | Capital of a political entity | Amsterdam, Brussels | +| **PPLS** | Populated places (multiple) | Settlement clusters | +| **PPLG** | Seat of government | The Hague | + +### EXCLUDED Feature Codes + +| Code | Description | Why Excluded | +|------|-------------|--------------| +| **PPLX** | Section of populated place | Neighborhoods, districts, quarters (e.g., "Binnenstad", "Amsterdam Binnenstad") | + +### Implementation + +```python +VALID_FEATURE_CODES = ('PPL', 'PPLA', 'PPLA2', 'PPLA3', 'PPLA4', 'PPLC', 'PPLS', 'PPLG') + +query = """ + SELECT name, feature_code, geonames_id, ... + FROM cities + WHERE country_code = ? + AND feature_code IN (?, ?, ?, ?, ?, ?, ?, ?) + ORDER BY distance_sq + LIMIT 1 +""" +cursor.execute(query, (country_code, *VALID_FEATURE_CODES)) +``` + +### Verification + +Always check `feature_code` in location_resolution metadata: + +```yaml +location_resolution: + geonames_name: Apeldoorn + feature_code: PPL # ← MUST be PPL, PPLA*, PPLC, PPLS, or PPLG +``` + +**If you see `feature_code: PPLX`**, the GHCID is WRONG and must be regenerated. + +--- + +## CRITICAL: Country Code Detection + +**Determine country code from entry data BEFORE calling GeoNames reverse geocoding.** + +GeoNames queries are country-specific. Using the wrong country code will return incorrect results. + +### Country Code Resolution Priority + +1. `zcbs_enrichment.country` - Most explicit source +2. `location.country` - Direct location field +3. `locations[].country` - Array location field +4. `original_entry.country` - CSV source field +5. `google_maps_enrichment.address` - Parse from address string +6. `wikidata_enrichment.located_in.label` - Infer from Wikidata +7. Default: `"NL"` (Netherlands) - Only if no other source + +### Example + +```python +# Determine country code FIRST +country_code = "NL" # Default + +if entry.get('zcbs_enrichment', {}).get('country'): + country_code = entry['zcbs_enrichment']['country'] +elif entry.get('google_maps_enrichment', {}).get('address', ''): + address = entry['google_maps_enrichment']['address'] + if ', Belgium' in address: + country_code = "BE" + elif ', Germany' in address: + country_code = "DE" + +# THEN call reverse geocoding +result = reverse_geocode_to_city(latitude, longitude, country_code) +``` + +--- + +## Settlement Resolution Process + +### Step 1: Coordinate-Based Resolution (Preferred) + +When coordinates are available, use reverse geocoding to find the nearest GeoNames settlement: + +```python +def resolve_settlement_from_coordinates(latitude: float, longitude: float, country_code: str = "NL") -> dict: + """ + Find the GeoNames settlement nearest to given coordinates. + + Returns: + { + 'settlement_name': 'Lelystad', # GeoNames standardized name + 'settlement_code': 'LEL', # 3-letter abbreviation + 'admin1_code': '16', # GeoNames admin1 code + 'region_code': 'FL', # ISO 3166-2 region code + 'geonames_id': 2751792, # GeoNames ID for provenance + 'distance_km': 0.5 # Distance from coords to settlement center + } + """ +``` + +### Step 2: Name-Based Resolution (Fallback) + +When only a settlement name is available (no coordinates), look up in GeoNames: + +```python +def resolve_settlement_from_name(name: str, country_code: str = "NL") -> dict: + """ + Find the GeoNames settlement matching the given name. + + Uses fuzzy matching and disambiguation when multiple matches exist. + """ +``` + +### Step 3: Manual Resolution (Last Resort) + +If GeoNames lookup fails, flag the entry for manual review with: +- `settlement_source: MANUAL` +- `settlement_needs_review: true` + +--- + +## GHCID Settlement Component Rules + +### Format + +The settlement component in GHCID uses a **3-letter uppercase code**: + +``` +NL-{REGION}-{SETTLEMENT}-{TYPE}-{ABBREV} + ^^^^^^^^^^^ + 3-letter code from GeoNames +``` + +### Code Generation Rules + +1. **Single-word settlements**: First 3 letters uppercase + - `Amsterdam` → `AMS` + - `Rotterdam` → `ROT` + - `Lelystad` → `LEL` + +2. **Settlements with Dutch articles** (`de`, `het`, `den`, `'s`): + - First letter of article + first 2 letters of main word + - `Den Haag` → `DHA` + - `'s-Hertogenbosch` → `SHE` + - `De Bilt` → `DBI` + +3. **Multi-word settlements** (no article): + - First letter of each word (up to 3) + - `Nieuw Amsterdam` → `NAM` + - `Oud Beijerland` → `OBE` + +4. **GeoNames Disambiguation Database**: + - For known problematic settlements, use pre-defined codes from disambiguation table + - Example: Both `Zwolle` (OV) and `Zwolle` (LI) exist - use `ZWO` with region for uniqueness + +### Measurement Point for Historical Custodians + +**Rule**: For heritage custodians that no longer exist or have historical coordinates, the **modern-day settlement** (as of 2025-12-01) is used. + +Rationale: +- GHCIDs should be stable over time +- Historical place names may have changed +- Modern settlements are easier to verify and look up +- GeoNames reflects current geographic reality + +Example: +- A museum that operated 1900-1950 in what was then "Nieuw Land" (before Flevoland province existed) +- Modern coordinates fall within Lelystad municipality +- GHCID uses `LEL` (Lelystad) as settlement code, not historical name + +--- + +## GeoNames Database Integration + +### Database Location + +``` +/data/reference/geonames.db +``` + +### Required Tables + +```sql +-- Cities/settlements table +CREATE TABLE cities ( + geonames_id INTEGER PRIMARY KEY, + name TEXT, -- Local name (may have diacritics) + ascii_name TEXT, -- ASCII-safe name for identifiers + country_code TEXT, -- ISO 3166-1 alpha-2 + admin1_code TEXT, -- First-level administrative division + admin1_name TEXT, -- Region/province name + latitude REAL, + longitude REAL, + population INTEGER, + feature_code TEXT -- PPL, PPLA, PPLC, etc. +); + +-- Disambiguation table for problematic settlements +CREATE TABLE settlement_codes ( + geonames_id INTEGER PRIMARY KEY, + country_code TEXT, + settlement_code TEXT, -- 3-letter code + is_primary BOOLEAN, -- Primary code for this settlement + notes TEXT +); +``` + +### Admin1 Code Mapping (Netherlands) + +**IMPORTANT**: GeoNames admin1 codes differ from historical numbering. Use this mapping: + +| GeoNames admin1 | Province | ISO 3166-2 | +|-----------------|----------|------------| +| 01 | Drenthe | NL-DR | +| 02 | Friesland | NL-FR | +| 03 | Gelderland | NL-GE | +| 04 | Groningen | NL-GR | +| 05 | Limburg | NL-LI | +| 06 | Noord-Brabant | NL-NB | +| 07 | Noord-Holland | NL-NH | +| 09 | Utrecht | NL-UT | +| 10 | Zeeland | NL-ZE | +| 11 | Zuid-Holland | NL-ZH | +| 15 | Overijssel | NL-OV | +| 16 | Flevoland | NL-FL | + +**Note**: Code 08 is not used in Netherlands (was assigned to former region). + +--- + +## Validation Requirements + +### Before GHCID Generation + +Every entry MUST have: +- [ ] Settlement name resolved via GeoNames +- [ ] `geonames_id` recorded in entry metadata +- [ ] Settlement code (3-letter) generated consistently +- [ ] Admin1/region code mapped correctly + +### Provenance Tracking + +Record GeoNames resolution in entry metadata: + +```yaml +location_resolution: + method: REVERSE_GEOCODE # or NAME_LOOKUP or MANUAL + geonames_id: 2751792 + geonames_name: Lelystad + settlement_code: LEL + admin1_code: "16" + region_code: FL + resolution_date: "2025-12-01T00:00:00Z" + source_coordinates: + latitude: 52.52111 + longitude: 5.43722 + distance_to_settlement_km: 0.5 +``` + +--- + +## CRITICAL: XXX Placeholders Are TEMPORARY - Research Required + +**XXX placeholders for region/settlement codes are NEVER acceptable as a final state.** + +When an entry has `XX` (unknown region) or `XXX` (unknown settlement), the agent MUST conduct research to resolve the location. + +### Resolution Strategy by Institution Type + +| Institution Type | Location Resolution Method | +|------------------|---------------------------| +| **Destroyed institution** | Use last known physical location before destruction | +| **Historical (closed)** | Use last operating location | +| **Refugee/diaspora org** | Use current headquarters OR original founding location | +| **Digital-only platform** | Use parent/founding organization's headquarters | +| **Decentralized initiative** | Use founding location or primary organizer location | +| **Unknown city, known country** | Research via Wikidata, Google Maps, official website | + +### Research Sources (Priority Order) + +1. **Wikidata** - P131 (located in), P159 (headquarters location), P625 (coordinates) +2. **Google Maps** - Search institution name +3. **Official Website** - Contact page, about page +4. **Web Archive** - archive.org for destroyed/closed institutions +5. **Academic Sources** - Papers, reports +6. **News Articles** - Particularly for destroyed heritage sites + +### Location Resolution Metadata + +When resolving XXX placeholders, update `location_resolution`: + +```yaml +location_resolution: + method: MANUAL_RESEARCH # Previously was NAME_LOOKUP with XXX + country_code: PS + region_code: GZ + region_name: Gaza Strip + city_code: GAZ + city_name: Gaza City + geonames_id: 281133 + research_date: "2025-12-06T00:00:00Z" + research_sources: + - type: wikidata + id: Q123456 + claim: P131 + - type: web_archive + url: https://web.archive.org/web/20231001/https://institution-website.org/contact + notes: "Located in Gaza City prior to destruction in 2024" +``` + +### File Renaming After Resolution + +When GHCID changes due to XXX resolution, the file MUST be renamed: + +```bash +# Before +data/custodian/PS-XX-XXX-A-NAPR.yaml + +# After +data/custodian/PS-GZ-GAZ-A-NAPR.yaml +``` + +### Prohibited Practices + +- ❌ Leaving XXX placeholders in production data +- ❌ Using "Online" or country name as location +- ❌ Skipping research because it's difficult +- ❌ Using XX/XXX for diaspora organizations + +--- + +## Error Handling + +### No GeoNames Match + +If a settlement cannot be resolved via automated lookup: +1. Log warning with entry details +2. Set `settlement_code: XXX` (temporary placeholder) +3. Set `settlement_needs_review: true` +4. Do NOT skip the entry - generate GHCID with XXX placeholder +5. **IMMEDIATELY** begin manual research to resolve + +### Multiple GeoNames Matches + +When multiple settlements match a name: +1. Use coordinates to disambiguate (if available) +2. Use admin1/region context (if available) +3. Use population as tiebreaker (prefer larger settlement) +4. Flag for manual review if still ambiguous + +### Coordinates Outside Country + +If coordinates fall outside the expected country: +1. Log warning +2. Use nearest settlement within country +3. Flag for manual review + +--- + +## Related Documentation + +- `AGENTS.md` - Section on GHCID generation +- `docs/PERSISTENT_IDENTIFIERS.md` - Complete GHCID specification +- `docs/GHCID_PID_SCHEME.md` - PID scheme details +- `scripts/enrich_nde_entries_ghcid.py` - Implementation + +--- + +## Changelog + +### v1.1.0 (2025-12-01) +- **CRITICAL**: Added feature code filtering rules + - MUST filter for PPL, PPLA, PPLA2, PPLA3, PPLA4, PPLC, PPLS, PPLG + - MUST exclude PPLX (neighborhoods/districts) + - Example: Apeldoorn (PPL) not "Binnenstad" (PPLX) +- **CRITICAL**: Added country code detection rules + - Must determine country from entry data BEFORE reverse geocoding + - Priority: zcbs_enrichment.country > location.country > address parsing + - Example: Belgian institutions use BE, not NL +- Added Belgium admin1 code mapping (BRU, VLG, WAL) + +### v1.0.0 (2025-12-01) +- Initial version +- Established GeoNames as authoritative source for settlement standardization +- Defined measurement point rule for historical custodians +- Documented admin1 code mapping for Netherlands diff --git a/frontend/public/schemas/20251121/linkml/rules/LEGAL_FORM_FILTER.md b/frontend/public/schemas/20251121/linkml/rules/LEGAL_FORM_FILTER.md new file mode 100644 index 0000000000..10cf384416 --- /dev/null +++ b/frontend/public/schemas/20251121/linkml/rules/LEGAL_FORM_FILTER.md @@ -0,0 +1,346 @@ +# Legal Form Filtering Rule for CustodianName + +**Rule ID**: LEGAL-FORM-FILTER +**Status**: MANDATORY +**Applies To**: CustodianName standardization +**Created**: 2025-12-02 + +--- + +## Overview + +**CRITICAL RULE**: Legal form designations MUST ALWAYS be filtered from `CustodianName`, even when the custodian self-identifies with them. + +This is the **ONE EXCEPTION** to the emic (insider name) principle in the Heritage Custodian Ontology. + +## Rationale + +### Why Legal Forms Are NOT Part of Identity + +1. **Legal Form ≠ Identity**: The legal structure is administrative metadata, not the custodian's core identity + - "Stichting Rijksmuseum" → Identity is "Rijksmuseum", legal form is "Stichting" + +2. **Legal Forms Change Over Time**: Organizations transform while identity persists + - Association → Foundation → Corporation (same museum, different legal structures) + +3. **Cross-Jurisdictional Consistency**: Same organization may have different legal forms in different countries + - "Getty Foundation" (US) = "Stichting Getty" (NL) = same identity + +4. **Deduplication**: Prevents false duplicates + - "Museum X" and "Stichting Museum X" should NOT be separate entities + +5. **ISO 20275 Alignment**: The Legal Entity Identifier (LEI) standard explicitly separates legal form from entity name + +### Where Legal Form IS Stored + +Legal form information is NOT discarded - it is stored in appropriate metadata fields: + +| Field | Location | Purpose | +|-------|----------|---------| +| `legal_form` | `CustodianLegalStatus` | ISO 20275 legal form code | +| `legal_name` | `CustodianLegalStatus` | Full registered name including legal form | +| `observed_name` | `CustodianObservation` | Original name as observed in source (may include legal form) | + +## Examples + +### Dutch Examples + +| Source Name | CustodianName | Legal Form | Notes | +|-------------|---------------|------------|-------| +| Stichting Rijksmuseum | Rijksmuseum | Stichting | Prefix removal | +| Hidde Nijland Stichting | Hidde Nijland | Stichting | Suffix removal | +| Stichting Het Loo | Het Loo | Stichting | Preserve article "Het" | +| Coöperatie Erfgoed | Erfgoed | Coöperatie | | +| Vereniging Ons Huis | Ons Huis | Vereniging | | +| Museum B.V. | Museum | B.V. | | + +### International Examples + +| Source Name | CustodianName | Legal Form | Language | +|-------------|---------------|------------|----------| +| The Getty Foundation | The Getty | Foundation | English | +| British Museum Trust Ltd | British Museum | Trust Ltd | English | +| Smithsonian Institution Inc. | Smithsonian Institution | Inc. | English | +| Fundação Biblioteca Nacional | Biblioteca Nacional | Fundação | Portuguese | +| Verein Deutsches Museum | Deutsches Museum | Verein | German | +| Association des Amis du Louvre | Amis du Louvre | Association | French | +| Fondazione Musei Civici | Musei Civici | Fondazione | Italian | +| Fundación Museo del Prado | Museo del Prado | Fundación | Spanish | + +--- + +## Global Legal Form Terms Reference + +### Dutch (Netherlands, Belgium-Flanders) + +**Foundations and Non-Profits:** +- Stichting (foundation) +- Vereniging (association) +- Coöperatie, Coöperatieve (cooperative) + +**Business Entities:** +- B.V., BV (besloten vennootschap - private limited company) +- N.V., NV (naamloze vennootschap - public limited company) +- V.O.F., VOF (vennootschap onder firma - general partnership) +- C.V., CV (commanditaire vennootschap - limited partnership) +- Maatschap (partnership) +- Eenmanszaak (sole proprietorship) + +### English (UK, US, Ireland, Australia, etc.) + +**Foundations and Non-Profits:** +- Foundation +- Trust +- Association +- Society +- Institute +- Institution (when followed by Inc./Ltd.) +- Charity +- Fund + +**Business Entities:** +- Inc., Incorporated +- Ltd., Limited +- LLC, L.L.C. (limited liability company) +- LLP, L.L.P. (limited liability partnership) +- Corp., Corporation +- Co., Company +- PLC, plc (public limited company - UK) +- Pty Ltd (proprietary limited - Australia) + +### German (Germany, Austria, Switzerland) + +**Foundations and Non-Profits:** +- Stiftung (foundation) +- Verein (association) +- e.V., eingetragener Verein (registered association) +- gGmbH (gemeinnützige GmbH - charitable limited company) + +**Business Entities:** +- GmbH (Gesellschaft mit beschränkter Haftung - limited liability company) +- AG (Aktiengesellschaft - stock corporation) +- KG (Kommanditgesellschaft - limited partnership) +- OHG (offene Handelsgesellschaft - general partnership) +- GmbH & Co. KG +- UG (Unternehmergesellschaft - mini-GmbH) + +### French (France, Belgium-Wallonia, Switzerland, Canada-Quebec) + +**Foundations and Non-Profits:** +- Fondation (foundation) +- Association (association) +- Fonds (fund) + +**Business Entities:** +- S.A., SA (société anonyme - public limited company) +- S.A.R.L., SARL (société à responsabilité limitée - private limited company) +- S.A.S., SAS (société par actions simplifiée) +- S.C.I., SCI (société civile immobilière) +- S.N.C., SNC (société en nom collectif - general partnership) +- S.C.S., SCS (société en commandite simple) +- EURL (entreprise unipersonnelle à responsabilité limitée) + +### Spanish (Spain, Latin America) + +**Foundations and Non-Profits:** +- Fundación (foundation) +- Asociación (association) +- Sociedad (society) - when not followed by commercial designator + +**Business Entities:** +- S.A., SA (sociedad anónima - public limited company) +- S.L., SL (sociedad limitada - private limited company) +- S.L.L., SLL (sociedad limitada laboral) +- S.Coop. (sociedad cooperativa) +- S.C., SC (sociedad colectiva - general partnership) +- S.Com., S. en C. (sociedad en comandita) + +### Portuguese (Portugal, Brazil) + +**Foundations and Non-Profits:** +- Fundação (foundation) +- Associação (association) +- Instituto (institute) + +**Business Entities:** +- Ltda., Limitada (limited liability company) +- S.A., SA (sociedade anônima - corporation) +- S/A +- Cia., Companhia (company) +- ME (microempresa) +- EPP (empresa de pequeno porte) + +### Italian (Italy, Switzerland-Ticino) + +**Foundations and Non-Profits:** +- Fondazione (foundation) +- Associazione (association) +- Ente (entity/institution) +- Onlus (non-profit organization) + +**Business Entities:** +- S.p.A., SpA (società per azioni - joint-stock company) +- S.r.l., Srl (società a responsabilità limitata - limited liability company) +- S.a.s., Sas (società in accomandita semplice) +- S.n.c., Snc (società in nome collettivo) +- S.c.a.r.l. (società cooperativa a responsabilità limitata) + +### Scandinavian Languages + +**Danish:** +- Fond (foundation) +- Forening (association) +- A/S (aktieselskab - public limited company) +- ApS (anpartsselskab - private limited company) + +**Swedish:** +- Stiftelse (foundation) +- Förening (association) +- AB (aktiebolag - limited company) + +**Norwegian:** +- Stiftelse (foundation) +- Forening (association) +- AS (aksjeselskap - limited company) +- ASA (allmennaksjeselskap - public limited company) + +### Other European Languages + +**Polish:** +- Fundacja (foundation) +- Stowarzyszenie (association) +- Sp. z o.o. (limited liability company) +- S.A. (joint-stock company) + +**Czech:** +- Nadace (foundation) +- Spolek (association) +- s.r.o. (limited liability company) +- a.s. (joint-stock company) + +**Hungarian:** +- Alapítvány (foundation) +- Egyesület (association) +- Kft. (limited liability company) +- Zrt. (private limited company) +- Nyrt. (public limited company) + +**Greek:** +- Ίδρυμα (Idryma - foundation) +- Σύλλογος (Syllogos - association) +- Α.Ε., ΑΕ (Ανώνυμη Εταιρεία - corporation) +- Ε.Π.Ε., ΕΠΕ (limited liability company) + +**Finnish:** +- Säätiö (foundation) +- Yhdistys (association) +- Oy (osakeyhtiö - limited company) +- Oyj (public limited company) + +### Asian Languages + +**Japanese:** +- 財団法人 (zaidan hōjin - incorporated foundation) +- 社団法人 (shadan hōjin - incorporated association) +- 株式会社, K.K. (kabushiki kaisha - corporation) +- 合同会社, G.K. (gōdō kaisha - LLC) +- 有限会社, Y.K. (yūgen kaisha - limited company) + +**Chinese:** +- 基金会 (jījīn huì - foundation) +- 协会 (xiéhuì - association) +- 有限公司 (yǒuxiàn gōngsī - limited company) +- 股份有限公司 (gǔfèn yǒuxiàn gōngsī - joint-stock company) + +**Korean:** +- 재단법인 (jaedan beobin - incorporated foundation) +- 사단법인 (sadan beobin - incorporated association) +- 주식회사 (jusik hoesa - corporation) +- 유한회사 (yuhan hoesa - limited company) + +### Middle Eastern Languages + +**Arabic:** +- مؤسسة (mu'assasa - foundation/institution) +- جمعية (jam'iyya - association) +- شركة (sharika - company) +- ش.م.م (limited liability company) +- ش.م.ع (public joint-stock company) + +**Hebrew:** +- עמותה (amuta - non-profit association) +- חל"צ (company for public benefit) +- בע"מ (limited company) + +**Turkish:** +- Vakıf (foundation) +- Dernek (association) +- A.Ş. (anonim şirket - joint-stock company) +- Ltd. Şti. (limited şirket - limited company) + +### Latin American Specific + +**Brazilian Portuguese:** +- OSCIP (organização da sociedade civil de interesse público) +- ONG (organização não governamental) +- EIRELI (empresa individual de responsabilidade limitada) + +**Mexican Spanish:** +- A.C. (asociación civil - civil association) +- S.C. (sociedad civil) +- S. de R.L. (sociedad de responsabilidad limitada) + +--- + +## Implementation Guidelines + +### Filtering Algorithm + +```python +def filter_legal_form(name: str, language: str = None) -> tuple[str, str | None]: + """ + Remove legal form terms from custodian name. + + Returns: + tuple: (filtered_name, legal_form_found) + """ + # Apply language-specific patterns first if language known + # Then apply universal patterns + # Handle both prefix and suffix positions + # Preserve articles (the, het, de, la, le, etc.) + pass +``` + +### Position Handling + +Legal forms can appear as: + +1. **Prefix**: "Stichting Rijksmuseum" → Remove "Stichting " +2. **Suffix**: "British Museum Trust Ltd" → Remove " Trust Ltd" +3. **Infix** (rare): Handle case-by-case + +### Edge Cases + +1. **Multiple legal forms**: "Foundation Trust Ltd" → Remove all +2. **Abbreviation variations**: "Inc." = "Inc" = "Incorporated" +3. **Case insensitivity**: "STICHTING" = "Stichting" = "stichting" +4. **With punctuation**: "B.V." = "BV" = "B.V" +5. **Compound terms**: "GmbH & Co. KG" → Remove entire compound + +### Validation Script + +Use `scripts/validate_organization_names.py` to detect names that still contain legal form terms after filtering. + +--- + +## References + +- ISO 20275:2017 - Financial services — Entity legal forms (ELF) +- GLEIF Legal Entity Identifier documentation +- LinkML Schema: `schemas/20251121/linkml/modules/classes/CustodianName.yaml` +- AGENTS.md: Rule 8 (Legal Form Filtering) + +--- + +**Last Updated**: 2025-12-02 +**Maintained By**: GLAM Heritage Custodian Ontology Project diff --git a/frontend/public/schemas/20251121/linkml/rules/README.md b/frontend/public/schemas/20251121/linkml/rules/README.md new file mode 100644 index 0000000000..4bb4277d0a --- /dev/null +++ b/frontend/public/schemas/20251121/linkml/rules/README.md @@ -0,0 +1,156 @@ +# Value Standardization Rules + +**Location**: `schemas/20251121/linkml/rules/` +**Purpose**: Data transformation and processing rules for achieving standardized values required by Heritage Custodian (HC) classes. + +--- + +## About These Rules + +These rules are **formally outside the LinkML schema convention** but document HOW data values are: +- Transformed +- Converted +- Processed +- Normalized + +to achieve the standardized values required by particular HC classes. + +**IMPORTANT**: These are NOT LinkML validation rules. They are **processing instructions** for data pipelines and extraction agents. + +--- + +## Rule Categories + +### 1. Name Standardization Rules + +| Rule ID | File | Applies To | Summary | +|---------|------|------------|---------| +| **LEGAL-FORM-FILTER** | [`LEGAL_FORM_FILTER.md`](LEGAL_FORM_FILTER.md) | `CustodianName` | Remove legal form terms (Stichting, Foundation, Inc.) from emic names | +| **ABBREV-CHAR-FILTER** | [`ABBREVIATION_RULES.md`](ABBREVIATION_RULES.md) | GHCID abbreviation | Remove special characters (&, /, +, @) and normalize diacritics to ASCII | +| **TRANSLIT-ISO** | [`TRANSLITERATION.md`](TRANSLITERATION.md) | GHCID abbreviation | Transliterate non-Latin scripts (Cyrillic, CJK, Arabic) using ISO standards | + +### 2. Geographic Standardization Rules + +| Rule ID | File | Applies To | Summary | +|---------|------|------------|---------| +| **GEONAMES-SETTLEMENT** | [`GEONAMES_SETTLEMENT.md`](GEONAMES_SETTLEMENT.md) | Settlement codes | Use GeoNames as single source for settlement names | +| **FEATURE-CODE-FILTER** | [`GEONAMES_SETTLEMENT.md`](GEONAMES_SETTLEMENT.md) | Reverse geocoding | Only use PPL* feature codes, never PPLX (neighborhoods) | + +### 3. Web Observation Rules + +| Rule ID | File | Applies To | Summary | +|---------|------|------------|---------| +| **XPATH-PROVENANCE** | [`XPATH_PROVENANCE.md`](XPATH_PROVENANCE.md) | `WebClaim` | Every web claim MUST have XPath pointer to archived HTML | + +### 4. Schema Evolution Rules + +| Rule ID | File | Applies To | Summary | +|---------|------|------------|---------| +| **ENUM-TO-CLASS** | [`ENUM_TO_CLASS.md`](ENUM_TO_CLASS.md) | Enums/Classes | When enum promoted to class hierarchy, delete original enum | + +--- + +## GLAMORCUBESFIXPHDNT Taxonomy Applicability + +Each rule primarily applies to certain custodian types: + +| Rule | Primary Types | All Types | +|------|--------------|-----------| +| LEGAL-FORM-FILTER | All | ✅ | +| ABBREV-SPECIAL-CHAR | All | ✅ | +| ABBREV-DIACRITICS | All | ✅ | +| TRANSLITERATION | International (non-Latin script countries) | Partial | +| GEONAMES-SETTLEMENT | All | ✅ | +| XPATH-PROVENANCE | D (Digital platforms) | Partial | + +--- + +## Integration with bronhouder.nl + +These rules are displayed under a separate "Regels" (Rules) category on the bronhouder.nl LinkML visualization page, distinct from: +- Classes +- Slots +- Enums +- Instances + +Each rule includes: +- Rule ID (short identifier) +- Applicable class(es) +- GLAMORCUBESFIXPHDNT type indicator +- Transformation examples +- Implementation code (Python) + +--- + +## Rule Template + +New rules should follow this template: + +```markdown +# Rule Title + +**Rule ID**: SHORT-ID +**Status**: MANDATORY | RECOMMENDED | OPTIONAL +**Applies To**: Class or slot name +**Created**: YYYY-MM-DD +**Updated**: YYYY-MM-DD + +--- + +## Summary + +One-paragraph summary of what this rule does. + +--- + +## Rationale + +Why this rule exists (numbered list of reasons). + +--- + +## Specification + +Detailed specification with examples. + +--- + +## Implementation + +Python code showing how to implement this rule. + +--- + +## Examples + +| Input | Output | Explanation | +|-------|--------|-------------| + +--- + +## Related Rules + +- Other related rules + +--- + +## Changelog + +| Date | Change | +|------|--------| +``` + +--- + +## File List + +``` +rules/ +├── README.md # This file (rule index) +├── ABBREVIATION_RULES.md # ABBREV-CHAR-FILTER: Special char + diacritics normalization +├── LEGAL_FORM_FILTER.md # LEGAL-FORM-FILTER: Legal form removal from emic names +├── GEONAMES_SETTLEMENT.md # GEONAMES-SETTLEMENT: Geographic standardization via GeoNames +├── XPATH_PROVENANCE.md # XPATH-PROVENANCE: WebClaim XPath requirements +├── TRANSLITERATION.md # TRANSLIT-ISO: Non-Latin script transliteration +└── ENUM_TO_CLASS.md # ENUM-TO-CLASS: Schema evolution pattern +``` diff --git a/frontend/public/schemas/20251121/linkml/rules/TRANSLITERATION.md b/frontend/public/schemas/20251121/linkml/rules/TRANSLITERATION.md new file mode 100644 index 0000000000..6819782d8f --- /dev/null +++ b/frontend/public/schemas/20251121/linkml/rules/TRANSLITERATION.md @@ -0,0 +1,337 @@ +# Transliteration Standards for Non-Latin Scripts + +**Rule ID**: TRANSLIT-ISO +**Status**: MANDATORY +**Applies To**: GHCID abbreviation generation from emic names in non-Latin scripts +**Created**: 2025-12-08 + +--- + +## Summary + +**When generating GHCID abbreviations from institution names written in non-Latin scripts, the emic name MUST first be transliterated to Latin characters using the designated ISO or recognized standard for that script.** + +This rule affects **170 institutions** across **21 languages** with non-Latin writing systems. + +### Key Principles + +1. **Emic name is preserved** - The original script is stored in `custodian_name.emic_name` +2. **Transliteration is for processing only** - Used to generate abbreviations +3. **ISO/recognized standards required** - No ad-hoc romanization +4. **Deterministic output** - Same input always produces same Latin output +5. **Existing GHCIDs grandfathered** - Only applies to NEW custodians + +--- + +## Transliteration Standards by Script/Language + +### Cyrillic Scripts + +| Language | ISO Code | Standard | Library/Tool | Notes | +|----------|----------|----------|--------------|-------| +| **Russian** | ru | ISO 9:1995 | `transliterate` | Scientific transliteration | +| **Ukrainian** | uk | ISO 9:1995 | `transliterate` | Includes Ukrainian-specific letters | +| **Bulgarian** | bg | ISO 9:1995 | `transliterate` | Uses same Cyrillic base | +| **Serbian** | sr | ISO 9:1995 | `transliterate` | Serbian Cyrillic variant | +| **Kazakh** | kk | ISO 9:1995 | `transliterate` | Cyrillic-based (pre-2023) | + +**Example**: +``` +Input: Институт восточных рукописей РАН +ISO 9: Institut vostocnyh rukopisej RAN +Abbrev: IVRRAN (after diacritic normalization) +``` + +--- + +### CJK Scripts + +#### Chinese (Hanzi) + +| Variant | Standard | Library/Tool | Notes | +|---------|----------|--------------|-------| +| Simplified | Hanyu Pinyin (ISO 7098) | `pypinyin` | Standard PRC romanization | +| Traditional | Hanyu Pinyin | `pypinyin` | Same standard applies | + +**Pinyin Rules**: +- Tone marks are OMITTED for abbreviation (diacritics removed anyway) +- Word boundaries follow natural spacing +- Proper nouns capitalized + +**Example**: +``` +Input: 东巴文化博物院 +Pinyin: Dongba Wenhua Bowuyuan +ASCII: Dongba Wenhua Bowuyuan +Abbrev: DWB +``` + +#### Japanese (Kanji/Kana) + +| Standard | Library/Tool | Notes | +|----------|--------------|-------| +| Modified Hepburn | `pykakasi`, `romkan` | Most widely used internationally | + +**Hepburn Rules**: +- Long vowels: o, u (normalized to o, u for abbreviation) +- Particles: ha (wa), wo (wo), he (e) +- Syllabic n: n = n (before vowels: n') + +**Example**: +``` +Input: 国立中央博物館 +Romaji: Kokuritsu Chuo Hakubutsukan +ASCII: Kokuritsu Chuo Hakubutsukan +Abbrev: KCH +``` + +#### Korean (Hangul) + +| Standard | Library/Tool | Notes | +|----------|--------------|-------| +| Revised Romanization (RR) | `korean-romanizer`, `hangul-romanize` | Official South Korean standard (2000) | + +**RR Rules**: +- No diacritics (unlike McCune-Reischauer) +- Consonant assimilation reflected in spelling +- Word boundaries at natural breaks + +**Example**: +``` +Input: 독립기념관 +RR: Dongnip Ginyeomgwan +Abbrev: DG +``` + +--- + +### Arabic Script + +| Language | ISO Code | Standard | Library/Tool | Notes | +|----------|----------|----------|--------------|-------| +| **Arabic** | ar | ISO 233-2:1993 | `arabic-transliteration` | Simplified standard | +| **Persian/Farsi** | fa | ISO 233-3:1999 | `persian-transliteration` | Persian extensions | +| **Urdu** | ur | ISO 233-3 + Urdu extensions | `urdu-transliteration` | Additional characters | + +**Example (Arabic)**: +``` +Input: المكتبة الوطنية للمملكة المغربية +ISO: al-Maktaba al-Wataniya lil-Mamlaka al-Maghribiya +ASCII: al-Maktaba al-Wataniya lil-Mamlaka al-Maghribiya +Abbrev: MWMM (skip "al-" articles) +``` + +--- + +### Hebrew Script + +| Standard | Library/Tool | Notes | +|----------|--------------|-------| +| ISO 259-3:1999 | `hebrew-transliteration` | Simplified romanization | + +**Example**: +``` +Input: ארכיון הסיפור העממי בישראל +ISO: Arkhiyon ha-Sipur ha-Amami be-Yisrael +ASCII: Arkhiyon ha-Sipur ha-Amami be-Yisrael +Abbrev: ASAY (skip "ha-" and "be-" articles) +``` + +--- + +### Greek Script + +| Standard | Library/Tool | Notes | +|----------|--------------|-------| +| ISO 843:1997 | `greek-transliteration` | Romanization of Greek | + +**Example**: +``` +Input: Αρχαιολογικό Μουσείο Θεσσαλονίκης +ISO: Archaiologiko Mouseio Thessalonikis +ASCII: Archaiologiko Mouseio Thessalonikis +Abbrev: AMT +``` + +--- + +### Indic Scripts + +| Language | Script | Standard | Library/Tool | +|----------|--------|----------|--------------| +| **Hindi** | Devanagari | ISO 15919 | `indic-transliteration` | +| **Bengali** | Bengali | ISO 15919 | `indic-transliteration` | +| **Nepali** | Devanagari | ISO 15919 | `indic-transliteration` | +| **Sinhala** | Sinhala | ISO 15919 | `indic-transliteration` | + +**Example (Hindi)**: +``` +Input: राजस्थान प्राच्यविद्या प्रतिष्ठान +ISO: Rajasthana Pracyavidya Pratishthana +ASCII: Rajasthana Pracyavidya Pratishthana +Abbrev: RPP +``` + +--- + +### Southeast Asian Scripts + +| Language | Script | Standard | Library/Tool | +|----------|--------|----------|--------------| +| **Thai** | Thai | ISO 11940-2 | `thai-romanization` | +| **Khmer** | Khmer | ALA-LC | `khmer-romanization` | + +**Thai Example**: +``` +Input: สำนักหอจดหมายเหตุแห่งชาติ +ISO: Samnak Ho Chotmaihet Haeng Chat +Abbrev: SHCHC +``` + +--- + +### Other Scripts + +| Language | Script | Standard | Library/Tool | +|----------|--------|----------|--------------| +| **Armenian** | Armenian | ISO 9985 | `armenian-transliteration` | +| **Georgian** | Georgian | ISO 9984 | `georgian-transliteration` | + +**Georgian Example**: +``` +Input: ხელნაწერთა ეროვნული ცენტრი +ISO: Khelnawerti Erovnuli Centri +ASCII: Khelnawerti Erovnuli Centri +Abbrev: KEC +``` + +--- + +## Implementation + +### Python Transliteration Utility + +```python +import unicodedata +from typing import Optional + +def detect_script(text: str) -> str: + """ + Detect the primary script of the input text. + + Returns one of: 'latin', 'cyrillic', 'chinese', 'japanese', + 'korean', 'arabic', 'hebrew', 'greek', 'devanagari', etc. + """ + script_ranges = { + 'cyrillic': (0x0400, 0x04FF), + 'arabic': (0x0600, 0x06FF), + 'hebrew': (0x0590, 0x05FF), + 'devanagari': (0x0900, 0x097F), + 'thai': (0x0E00, 0x0E7F), + 'greek': (0x0370, 0x03FF), + 'korean': (0xAC00, 0xD7AF), + 'chinese': (0x4E00, 0x9FFF), + } + + for char in text: + code = ord(char) + for script, (start, end) in script_ranges.items(): + if start <= code <= end: + return script + + return 'latin' + + +def transliterate_for_abbreviation(emic_name: str, lang: str) -> str: + """ + Transliterate emic name for GHCID abbreviation generation. + + Args: + emic_name: Institution name in original script + lang: ISO 639-1 language code + + Returns: + Transliterated name ready for abbreviation extraction + """ + import re + + # Step 1: Transliterate to Latin (implementation depends on script) + latin = transliterate(emic_name, lang) + + # Step 2: Normalize diacritics + normalized = unicodedata.normalize('NFD', latin) + ascii_text = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn') + + # Step 3: Remove special characters (except spaces) + clean = re.sub(r'[^a-zA-Z\s]', ' ', ascii_text) + + # Step 4: Normalize whitespace + clean = ' '.join(clean.split()) + + return clean +``` + +--- + +## Skip Words by Language + +When extracting abbreviations from transliterated text, skip these articles/prepositions: + +### Arabic +- `al-` (the definite article) +- `bi-`, `li-`, `fi-` (prepositions) + +### Hebrew +- `ha-` (the) +- `ve-` (and) +- `be-`, `le-`, `me-` (prepositions) + +### Persian +- `-e`, `-ye` (ezafe connector) +- `va` (and) + +### CJK Languages +- No skip words (particles are integral to meaning) + +### Indic Languages +- `ka`, `ki`, `ke` (Hindi: of) +- `aur` (Hindi: and) + +--- + +## Validation + +### Check Transliteration Output + +```python +def validate_transliteration(result: str) -> bool: + """ + Validate that transliteration output contains only ASCII letters and spaces. + """ + import re + return bool(re.match(r'^[a-zA-Z\s]+$', result)) +``` + +### Manual Review Queue + +Non-Latin institutions should be flagged for manual review if: +1. Transliteration library not available for that script +2. Confidence in transliteration is low +3. Institution has multiple official romanizations + +--- + +## Related Documentation + +- `AGENTS.md` - Rule 12: Transliteration Standards +- `rules/ABBREVIATION_RULES.md` - Character filtering after transliteration +- `docs/TRANSLITERATION_CONVENTIONS.md` - Extended examples and edge cases +- `scripts/transliterate_emic_names.py` - Production transliteration script + +--- + +## Changelog + +| Date | Change | +|------|--------| +| 2025-12-08 | Initial standards document created | diff --git a/frontend/public/schemas/20251121/linkml/rules/XPATH_PROVENANCE.md b/frontend/public/schemas/20251121/linkml/rules/XPATH_PROVENANCE.md new file mode 100644 index 0000000000..3581453776 --- /dev/null +++ b/frontend/public/schemas/20251121/linkml/rules/XPATH_PROVENANCE.md @@ -0,0 +1,210 @@ +# WebObservation XPath Provenance Rules + +**Rule ID**: XPATH-PROVENANCE +**Status**: MANDATORY +**Applies To**: WebClaim extraction from websites +**Created**: 2025-11-29 + +--- + +## Core Principle: Every Claim MUST Have Verifiable Provenance + +**If a claim allegedly came from a webpage, it MUST have an XPath pointer to the exact location in the archived HTML where that value appears. Claims without XPath provenance are considered FABRICATED and must be removed.** + +This is not about "confidence" or "uncertainty" - it's about **verifiability**. Either the claim value exists in the HTML at a specific XPath, or it was hallucinated/fabricated by an LLM. + +--- + +## Required Fields for WebObservation Claims + +Every claim in `web_enrichment.claims` MUST have: + +| Field | Required | Description | +|-------|----------|-------------| +| `claim_type` | YES | Type of claim (full_name, description, email, etc.) | +| `claim_value` | YES | The extracted value | +| `source_url` | YES | URL the claim was extracted from | +| `retrieved_on` | YES | ISO 8601 timestamp when page was archived | +| `xpath` | YES | XPath to the element containing this value | +| `html_file` | YES | Relative path to archived HTML file | +| `xpath_match_score` | YES | 1.0 for exact match, <1.0 for fuzzy match | + +### Example - CORRECT (Verifiable) + +```yaml +web_enrichment: + claims: + - claim_type: full_name + claim_value: Historische Vereniging Nijeveen + source_url: https://historischeverenigingnijeveen.nl/ + retrieved_on: "2025-11-29T12:28:00Z" + xpath: /[document][1]/html[1]/body[1]/div[6]/div[1]/table[3]/tbody[1]/tr[1]/td[1]/p[6] + html_file: web/0021/historischeverenigingnijeveen.nl/rendered.html + xpath_match_score: 1.0 +``` + +### Example - WRONG (Fabricated - Must Be Removed) + +```yaml +web_enrichment: + claims: + - claim_type: full_name + claim_value: Historische Vereniging Nijeveen + confidence: 0.95 # ← NO! This is meaningless without XPath +``` + +--- + +## Forbidden: Confidence Scores Without XPath + +**NEVER use arbitrary confidence scores for web-extracted claims.** + +Confidence scores like `0.95`, `0.90`, `0.85` are meaningless because: +1. There is NO methodology defining what these numbers mean +2. They cannot be verified or reproduced +3. They give false impression of rigor +4. They mask the fact that claims may be fabricated + +If a value appears in the HTML → `xpath_match_score: 1.0` +If a value does NOT appear in the HTML → **REMOVE THE CLAIM** + +--- + +## Website Archiving Workflow + +### Step 1: Archive the Website + +Use Playwright to archive websites with JavaScript rendering: + +```bash +python scripts/fetch_website_playwright.py + +# Example: +python scripts/fetch_website_playwright.py 0021 https://historischeverenigingnijeveen.nl/ +``` + +This creates: +``` +data/nde/enriched/entries/web/{entry_number}/{domain}/ +├── index.html # Raw HTML as received +├── rendered.html # HTML after JS execution +├── content.md # Markdown conversion +└── metadata.yaml # XPath extractions for provenance +``` + +### Step 2: Add XPath Provenance to Claims + +Run the XPath migration script: + +```bash +python scripts/add_xpath_provenance.py + +# Or for specific entries: +python scripts/add_xpath_provenance.py --entries 0021,0022,0023 +``` + +This script: +1. Reads each entry's `web_enrichment.claims` +2. Searches archived HTML for each claim value +3. Adds `xpath` + `html_file` if found +4. **REMOVES claims that cannot be verified** (stores in `removed_unverified_claims`) + +### Step 3: Audit Removed Claims + +Check `removed_unverified_claims` in each entry file: + +```yaml +removed_unverified_claims: + - claim_type: phone + claim_value: "+31 6 12345678" + reason: "Value not found in archived HTML - likely fabricated" + removed_on: "2025-11-29T14:30:00Z" +``` + +These claims were NOT in the HTML and should NOT be restored without proper sourcing. + +--- + +## Claim Types and Expected Sources + +| Claim Type | Expected Source | Notes | +|------------|-----------------|-------| +| `full_name` | Page title, heading, logo text | Usually in `

`, ``, or prominent `<div>` | +| `description` | Meta description, about text | Check `<meta name="description">` first | +| `email` | Contact page, footer | Often in `<a href="mailto:...">` | +| `phone` | Contact page, footer | May need normalization | +| `address` | Contact page, footer | Check for structured data too | +| `social_media` | Footer, contact page | Links to social platforms | +| `opening_hours` | Contact/visit page | May be in structured data | + +--- + +## XPath Matching Strategy + +The `add_xpath_provenance.py` script uses this matching strategy: + +1. **Exact match**: Claim value appears exactly in element text +2. **Normalized match**: After whitespace normalization +3. **Substring match**: Claim value is substring of element text (score < 1.0) + +Priority order for matching: +1. `rendered.html` (after JS execution) - preferred +2. `index.html` (raw HTML) - fallback + +--- + +## Integration with LinkML Schema + +The `WebClaim` class in the LinkML schema requires: + +```yaml +# schemas/20251121/linkml/modules/classes/WebClaim.yaml +WebClaim: + slots: + - source_url # Required + - retrieved_on # Required (timestamp) + - xpath # Required for claims + - html_archive_path # Path to archived HTML +``` + +--- + +## Rules for AI Agents + +### When Extracting Claims from Websites + +1. **ALWAYS archive the website first** using Playwright +2. **ALWAYS extract claims with XPath provenance** using the archived HTML +3. **NEVER invent or infer claims** not present in the HTML +4. **NEVER use confidence scores** without XPath backing + +### When Processing Existing Claims + +1. **Verify each claim** against archived HTML +2. **Add XPath provenance** to verified claims +3. **REMOVE fabricated claims** that cannot be verified +4. **Document removed claims** in `removed_unverified_claims` + +### When Reviewing Data Quality + +1. Claims with `xpath` + `html_file` = **VERIFIED** +2. Claims with only `confidence` = **SUSPECT** (migrate or remove) +3. Claims in `removed_unverified_claims` = **FABRICATED** (do not restore) + +--- + +## Scripts Reference + +| Script | Purpose | +|--------|---------| +| `scripts/fetch_website_playwright.py` | Archive website with Playwright | +| `scripts/add_xpath_provenance.py` | Add XPath to claims, remove fabricated | +| `scripts/batch_fetch_websites.py` | Batch archive multiple entries | + +--- + +## Version History + +- **2025-11-29**: Initial version - established XPath provenance requirement +- Replaced confidence scores with verifiable XPath pointers +- Established policy of removing fabricated claims diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1bc4d45fb8..ddab7c4076 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import NDEMapPage from './pages/NDEMapPageMapLibre'; import NDEStatsPage from './pages/NDEStatsPage'; import ProjectPlanPage from './pages/ProjectPlanPage'; import OverviewPage from './pages/OverviewPage'; +import GesprekPage from './pages/GesprekPage'; import './App.css'; // Create router configuration with protected routes @@ -88,6 +89,10 @@ const router = createBrowserRouter([ path: 'overview', element: <OverviewPage />, }, + { + path: 'gesprek', + element: <GesprekPage />, + }, ], }, ]); diff --git a/frontend/src/components/database/EmbeddingProjector.tsx b/frontend/src/components/database/EmbeddingProjector.tsx new file mode 100644 index 0000000000..65ad2b8a36 --- /dev/null +++ b/frontend/src/components/database/EmbeddingProjector.tsx @@ -0,0 +1,1404 @@ +/** + * Embedding Projector Component + * + * A TensorFlow Projector-inspired visualization tool for high-dimensional embeddings. + * Supports multiple dimensionality reduction techniques: + * - PCA (Principal Component Analysis) - fast, deterministic + * - UMAP (Uniform Manifold Approximation and Projection) - preserves local structure + * - t-SNE (t-distributed Stochastic Neighbor Embedding) - cluster visualization + * + * Features: + * - 2D/3D interactive visualization + * - Point search and filtering + * - Nearest neighbor exploration + * - Color coding by metadata fields + * - Pan, zoom, rotate controls + * - Export/import projections + * + * References: + * - TensorFlow Embedding Projector: https://projector.tensorflow.org/ + * - UMAP: https://umap-learn.readthedocs.io/ + * - t-SNE: https://distill.pub/2016/misread-tsne/ + */ + +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import * as d3 from 'd3'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { UMAP } from 'umap-js'; +import { useLanguage } from '@/contexts/LanguageContext'; + +// Types +export interface EmbeddingPoint { + id: string | number; + vector: number[]; + payload: Record<string, unknown>; +} + +export interface ProjectedPoint { + x: number; + y: number; + z?: number; + originalIndex: number; +} + +export type ProjectionMethod = 'pca' | 'umap' | 'tsne'; +export type ViewMode = '2d' | '3d'; + +interface EmbeddingProjectorProps { + points: EmbeddingPoint[]; + onPointSelect?: (point: EmbeddingPoint | null) => void; + colorByField?: string; + height?: number; + width?: number; +} + +// Translation strings +const TEXT = { + title: { nl: 'Embedding Projector', en: 'Embedding Projector' }, + description: { + nl: 'Visualiseer hoog-dimensionale embeddings in 2D/3D', + en: 'Visualize high-dimensional embeddings in 2D/3D', + }, + projectionMethod: { nl: 'Projectie methode', en: 'Projection method' }, + viewMode: { nl: 'Weergave', en: 'View mode' }, + colorBy: { nl: 'Kleur op', en: 'Color by' }, + noField: { nl: 'Geen', en: 'None' }, + neighbors: { nl: 'Buren', en: 'Neighbors' }, + search: { nl: 'Zoeken...', en: 'Search...' }, + computing: { nl: 'Berekenen...', en: 'Computing...' }, + run: { nl: 'Start', en: 'Run' }, + stop: { nl: 'Stop', en: 'Stop' }, + reset: { nl: 'Reset', en: 'Reset' }, + iteration: { nl: 'Iteratie', en: 'Iteration' }, + perplexity: { nl: 'Perplexiteit', en: 'Perplexity' }, + learningRate: { nl: 'Leersnelheid', en: 'Learning rate' }, + umapNeighbors: { nl: 'Buren', en: 'Neighbors' }, + minDist: { nl: 'Min afstand', en: 'Min distance' }, + spread: { nl: 'Spreiding', en: 'Spread' }, + pcaComponents: { nl: 'Componenten', en: 'Components' }, + pointsLoaded: { nl: 'punten geladen', en: 'points loaded' }, + dimensions: { nl: 'dimensies', en: 'dimensions' }, + selectedPoint: { nl: 'Geselecteerd punt', en: 'Selected point' }, + nearestNeighbors: { nl: 'Dichtstbijzijnde buren', en: 'Nearest neighbors' }, + distance: { nl: 'Afstand', en: 'Distance' }, + showLabels: { nl: 'Labels tonen', en: 'Show labels' }, + sphereize: { nl: 'Sferiseren', en: 'Sphereize' }, + variance: { nl: 'Variantie', en: 'Variance' }, +}; + +// Color palette for categorical data +const COLORS = [ + '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', + '#f43f5e', '#ef4444', '#f97316', '#f59e0b', '#eab308', + '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4', + '#0ea5e9', '#3b82f6', '#1d4ed8', +]; + +/** + * Proper PCA implementation using power iteration for top eigenvectors + */ +function computePCA(vectors: number[][], nComponents: number = 2): { + projected: number[][]; + variance: number[]; + explained: number[]; +} { + if (vectors.length === 0) return { projected: [], variance: [], explained: [] }; + + const n = vectors.length; + const d = vectors[0].length; + + // Center the data + const means = new Array(d).fill(0); + for (const vec of vectors) { + for (let i = 0; i < d; i++) { + means[i] += vec[i] / n; + } + } + + const centered = vectors.map(vec => vec.map((v, i) => v - means[i])); + + // Compute covariance matrix (d x d can be large, so we use X^T X / n) + // For efficiency with high-d data, we compute principal components via power iteration + + const components: number[][] = []; + const eigenvalues: number[] = []; + let workingData = centered.map(row => [...row]); + + for (let comp = 0; comp < Math.min(nComponents, d); comp++) { + // Initialize random vector + let v = new Array(d).fill(0).map(() => Math.random() - 0.5); + let norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)); + v = v.map(x => x / norm); + + // Power iteration (50 iterations should be enough for convergence) + for (let iter = 0; iter < 50; iter++) { + // Multiply by X^T X + const Xv = workingData.map(row => row.reduce((s, x, i) => s + x * v[i], 0)); + const XtXv = new Array(d).fill(0); + for (let i = 0; i < n; i++) { + for (let j = 0; j < d; j++) { + XtXv[j] += workingData[i][j] * Xv[i]; + } + } + + // Normalize + norm = Math.sqrt(XtXv.reduce((s, x) => s + x * x, 0)); + if (norm > 1e-10) { + v = XtXv.map(x => x / norm); + } + } + + // Compute eigenvalue + const Xv = workingData.map(row => row.reduce((s, x, i) => s + x * v[i], 0)); + const eigenvalue = Xv.reduce((s, x) => s + x * x, 0) / n; + + components.push(v); + eigenvalues.push(eigenvalue); + + // Deflate: remove this component from data + for (let i = 0; i < n; i++) { + const proj = workingData[i].reduce((s, x, j) => s + x * v[j], 0); + for (let j = 0; j < d; j++) { + workingData[i][j] -= proj * v[j]; + } + } + } + + // Project data onto principal components + const projected = centered.map(vec => + components.map(comp => vec.reduce((s, x, i) => s + x * comp[i], 0)) + ); + + // Calculate explained variance ratio + const totalVariance = eigenvalues.reduce((s, e) => s + e, 0) || 1; + const explained = eigenvalues.map(e => (e / totalVariance) * 100); + + // Normalize to [-1, 1] range + const mins = new Array(nComponents).fill(Infinity); + const maxs = new Array(nComponents).fill(-Infinity); + + for (const point of projected) { + for (let i = 0; i < point.length; i++) { + mins[i] = Math.min(mins[i], point[i]); + maxs[i] = Math.max(maxs[i], point[i]); + } + } + + const normalized = projected.map(point => + point.map((v, i) => { + const range = maxs[i] - mins[i]; + return range > 0 ? ((v - mins[i]) / range) * 2 - 1 : 0; + }) + ); + + return { + projected: normalized, + variance: eigenvalues, + explained + }; +} + +/** + * Simple t-SNE implementation + * Based on the Barnes-Hut approximation algorithm + */ +function computeTSNE( + vectors: number[][], + nComponents: number = 2, + options: { + perplexity?: number; + learningRate?: number; + iterations?: number; + onProgress?: (iteration: number, error: number) => void; + } = {} +): number[][] { + const { + perplexity = 30, + learningRate = 200, + iterations = 500, + onProgress + } = options; + + if (vectors.length === 0) return []; + + const n = vectors.length; + + // Compute pairwise distances + const distances: number[][] = []; + for (let i = 0; i < n; i++) { + distances[i] = []; + for (let j = 0; j < n; j++) { + let d = 0; + for (let k = 0; k < vectors[i].length; k++) { + const diff = vectors[i][k] - vectors[j][k]; + d += diff * diff; + } + distances[i][j] = d; + } + } + + // Compute Gaussian perplexities + const P: number[][] = []; + for (let i = 0; i < n; i++) { + P[i] = new Array(n).fill(0); + + // Binary search for sigma + let sigma = 1.0; + let sigmaMin = 1e-10; + let sigmaMax = 1e10; + + for (let iter = 0; iter < 50; iter++) { + let sumP = 0; + for (let j = 0; j < n; j++) { + if (i !== j) { + P[i][j] = Math.exp(-distances[i][j] / (2 * sigma * sigma)); + sumP += P[i][j]; + } + } + + // Normalize + for (let j = 0; j < n; j++) { + P[i][j] /= sumP || 1; + } + + // Compute entropy + let entropy = 0; + for (let j = 0; j < n; j++) { + if (P[i][j] > 1e-10) { + entropy -= P[i][j] * Math.log2(P[i][j]); + } + } + + const perpCurrent = Math.pow(2, entropy); + + if (Math.abs(perpCurrent - perplexity) < 1e-5) break; + + if (perpCurrent > perplexity) { + sigmaMax = sigma; + sigma = (sigma + sigmaMin) / 2; + } else { + sigmaMin = sigma; + sigma = (sigma + sigmaMax) / 2; + } + } + } + + // Symmetrize + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const pij = (P[i][j] + P[j][i]) / (2 * n); + P[i][j] = pij; + P[j][i] = pij; + } + } + + // Initialize embedding randomly + const Y: number[][] = []; + for (let i = 0; i < n; i++) { + Y[i] = []; + for (let d = 0; d < nComponents; d++) { + Y[i][d] = (Math.random() - 0.5) * 0.0001; + } + } + + // Gradient descent + const gains: number[][] = Y.map(row => row.map(() => 1.0)); + const momentum: number[][] = Y.map(row => row.map(() => 0)); + + for (let iter = 0; iter < iterations; iter++) { + // Compute Q distribution (Student-t with 1 DoF) + const Q: number[][] = []; + let sumQ = 0; + for (let i = 0; i < n; i++) { + Q[i] = []; + for (let j = 0; j < n; j++) { + if (i !== j) { + let d = 0; + for (let k = 0; k < nComponents; k++) { + const diff = Y[i][k] - Y[j][k]; + d += diff * diff; + } + Q[i][j] = 1 / (1 + d); + sumQ += Q[i][j]; + } else { + Q[i][j] = 0; + } + } + } + + // Normalize Q + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + Q[i][j] /= sumQ || 1; + } + } + + // Compute gradients + const grad: number[][] = Y.map(row => row.map(() => 0)); + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + if (i !== j) { + const mult = 4 * (P[i][j] - Q[i][j]) * Q[i][j] * sumQ; + for (let d = 0; d < nComponents; d++) { + grad[i][d] += mult * (Y[i][d] - Y[j][d]); + } + } + } + } + + // Update with momentum + const mom = iter < 250 ? 0.5 : 0.8; + for (let i = 0; i < n; i++) { + for (let d = 0; d < nComponents; d++) { + const sign = grad[i][d] * momentum[i][d] >= 0; + gains[i][d] = sign ? gains[i][d] * 0.8 : gains[i][d] + 0.2; + gains[i][d] = Math.max(gains[i][d], 0.01); + + momentum[i][d] = mom * momentum[i][d] - learningRate * gains[i][d] * grad[i][d]; + Y[i][d] += momentum[i][d]; + } + } + + // Center + const means = new Array(nComponents).fill(0); + for (let i = 0; i < n; i++) { + for (let d = 0; d < nComponents; d++) { + means[d] += Y[i][d] / n; + } + } + for (let i = 0; i < n; i++) { + for (let d = 0; d < nComponents; d++) { + Y[i][d] -= means[d]; + } + } + + // Report progress + if (onProgress && iter % 10 === 0) { + let error = 0; + for (let i = 0; i < n; i++) { + for (let j = 0; j < n; j++) { + if (P[i][j] > 1e-10) { + error += P[i][j] * Math.log(P[i][j] / (Q[i][j] + 1e-10)); + } + } + } + onProgress(iter, error); + } + } + + // Normalize to [-1, 1] + const mins = new Array(nComponents).fill(Infinity); + const maxs = new Array(nComponents).fill(-Infinity); + + for (const point of Y) { + for (let i = 0; i < nComponents; i++) { + mins[i] = Math.min(mins[i], point[i]); + maxs[i] = Math.max(maxs[i], point[i]); + } + } + + return Y.map(point => + point.map((v, i) => { + const range = maxs[i] - mins[i]; + return range > 0 ? ((v - mins[i]) / range) * 2 - 1 : 0; + }) + ); +} + +/** + * Compute UMAP projection using umap-js library + */ +async function computeUMAP( + vectors: number[][], + nComponents: number = 2, + options: { + nNeighbors?: number; + minDist?: number; + spread?: number; + onProgress?: (epoch: number) => void; + } = {} +): Promise<number[][]> { + const { + nNeighbors = 15, + minDist = 0.1, + spread = 1.0, + // onProgress - available for future use + } = options; + + if (vectors.length === 0) return []; + + const umap = new UMAP({ + nComponents, + nNeighbors: Math.min(nNeighbors, vectors.length - 1), + minDist, + spread, + }); + + // Fit the data + const embedding = umap.fit(vectors); + + // Normalize to [-1, 1] + const mins = new Array(nComponents).fill(Infinity); + const maxs = new Array(nComponents).fill(-Infinity); + + for (const point of embedding) { + for (let i = 0; i < nComponents; i++) { + mins[i] = Math.min(mins[i], point[i]); + maxs[i] = Math.max(maxs[i], point[i]); + } + } + + return embedding.map(point => + point.map((v, i) => { + const range = maxs[i] - mins[i]; + return range > 0 ? ((v - mins[i]) / range) * 2 - 1 : 0; + }) + ); +} + +/** + * Find k nearest neighbors in original space + */ +function findNearestNeighbors( + targetIndex: number, + vectors: number[][], + k: number = 10, + metric: 'euclidean' | 'cosine' = 'cosine' +): { index: number; distance: number }[] { + const target = vectors[targetIndex]; + const distances: { index: number; distance: number }[] = []; + + for (let i = 0; i < vectors.length; i++) { + if (i === targetIndex) continue; + + let dist: number; + if (metric === 'cosine') { + // Cosine distance = 1 - cosine similarity + let dotProduct = 0; + let normA = 0; + let normB = 0; + for (let j = 0; j < target.length; j++) { + dotProduct += target[j] * vectors[i][j]; + normA += target[j] * target[j]; + normB += vectors[i][j] * vectors[i][j]; + } + const similarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB) || 1); + dist = 1 - similarity; + } else { + // Euclidean distance + dist = 0; + for (let j = 0; j < target.length; j++) { + const diff = target[j] - vectors[i][j]; + dist += diff * diff; + } + dist = Math.sqrt(dist); + } + + distances.push({ index: i, distance: dist }); + } + + return distances.sort((a, b) => a.distance - b.distance).slice(0, k); +} + +/** + * Main Embedding Projector Component + */ +export function EmbeddingProjector({ + points, + onPointSelect, + colorByField: initialColorByField, + height = 600, + width: _width, +}: EmbeddingProjectorProps) { + const { language } = useLanguage(); + const t = (key: keyof typeof TEXT) => TEXT[key][language]; + + const svgRef = useRef<SVGSVGElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const threeContainerRef = useRef<HTMLDivElement>(null); + + // Three.js refs + const sceneRef = useRef<THREE.Scene | null>(null); + const cameraRef = useRef<THREE.PerspectiveCamera | null>(null); + const rendererRef = useRef<THREE.WebGLRenderer | null>(null); + const controlsRef = useRef<OrbitControls | null>(null); + const pointCloudRef = useRef<THREE.Points | null>(null); + const animationFrameRef = useRef<number | null>(null); + + // State + const [projectionMethod, setProjectionMethod] = useState<ProjectionMethod>('pca'); + const [viewMode, setViewMode] = useState<ViewMode>('2d'); + const [projectedPoints, setProjectedPoints] = useState<ProjectedPoint[]>([]); + const [isComputing, setIsComputing] = useState(false); + const [computeProgress, setComputeProgress] = useState<{ iteration: number; total: number } | null>(null); + const [selectedIndex, setSelectedIndex] = useState<number | null>(null); + const [hoveredIndex, setHoveredIndex] = useState<number | null>(null); + const [nearestNeighbors, setNearestNeighbors] = useState<{ index: number; distance: number }[]>([]); + const [searchQuery, setSearchQuery] = useState(''); + const [colorByField, setColorByField] = useState(initialColorByField || ''); + const [showLabels, setShowLabels] = useState(false); + const [neighborCount, setNeighborCount] = useState(10); + + // UMAP parameters + const [umapNeighbors, setUmapNeighbors] = useState(15); + const [umapMinDist, setUmapMinDist] = useState(0.1); + + // t-SNE parameters + const [tsnePerplexity, setTsnePerplexity] = useState(30); + const [tsneLearningRate, setTsneLearningRate] = useState(200); + + // PCA variance explained + const [pcaVariance, setPcaVariance] = useState<number[]>([]); + + // Extract unique payload fields + const payloadFields = useMemo(() => { + const fields = new Set<string>(); + for (const point of points) { + for (const key of Object.keys(point.payload)) { + fields.add(key); + } + } + return Array.from(fields).sort(); + }, [points]); + + // Get unique categories for color legend + const fieldCategories = useMemo(() => { + if (!colorByField) return []; + const values = new Set<string>(); + for (const point of points) { + const value = point.payload[colorByField]; + if (value !== undefined && value !== null) { + values.add(String(value)); + } + } + return Array.from(values).slice(0, 20); + }, [points, colorByField]); + + // Filter points by search query + const filteredIndices = useMemo(() => { + if (!searchQuery.trim()) return null; + + const query = searchQuery.toLowerCase(); + const matches: number[] = []; + + points.forEach((point, index) => { + // Search in ID + if (String(point.id).toLowerCase().includes(query)) { + matches.push(index); + return; + } + // Search in payload + for (const value of Object.values(point.payload)) { + if (String(value).toLowerCase().includes(query)) { + matches.push(index); + return; + } + } + }); + + return matches; + }, [points, searchQuery]); + + // Get color for a point + const getPointColor = useCallback((index: number) => { + if (!colorByField) return COLORS[0]; + + const value = String(points[index]?.payload[colorByField] ?? ''); + const categoryIndex = fieldCategories.indexOf(value); + return categoryIndex >= 0 ? COLORS[categoryIndex % COLORS.length] : '#94a3b8'; + }, [colorByField, fieldCategories, points]); + + // Compute projection + const runProjection = useCallback(async () => { + if (points.length === 0) return; + + setIsComputing(true); + setComputeProgress(null); + + const vectors = points.map(p => p.vector); + const nComponents = viewMode === '3d' ? 3 : 2; + + try { + let projected: number[][]; + + switch (projectionMethod) { + case 'pca': { + const result = computePCA(vectors, nComponents); + projected = result.projected; + setPcaVariance(result.explained); + break; + } + + case 'umap': { + projected = await computeUMAP(vectors, nComponents, { + nNeighbors: umapNeighbors, + minDist: umapMinDist, + onProgress: (epoch) => setComputeProgress({ iteration: epoch, total: 200 }), + }); + break; + } + + case 'tsne': { + projected = computeTSNE(vectors, nComponents, { + perplexity: tsnePerplexity, + learningRate: tsneLearningRate, + iterations: 500, + onProgress: (iter) => setComputeProgress({ iteration: iter, total: 500 }), + }); + break; + } + + default: + projected = []; + } + + setProjectedPoints(projected.map((coords, i) => ({ + x: coords[0], + y: coords[1], + z: coords[2], + originalIndex: i, + }))); + } finally { + setIsComputing(false); + setComputeProgress(null); + } + }, [points, projectionMethod, viewMode, umapNeighbors, umapMinDist, tsnePerplexity, tsneLearningRate]); + + // Find nearest neighbors when point is selected + useEffect(() => { + if (selectedIndex !== null && points.length > 0) { + const neighbors = findNearestNeighbors( + selectedIndex, + points.map(p => p.vector), + neighborCount + ); + setNearestNeighbors(neighbors); + onPointSelect?.(points[selectedIndex]); + } else { + setNearestNeighbors([]); + onPointSelect?.(null); + } + }, [selectedIndex, points, neighborCount, onPointSelect]); + + // D3 visualization + useEffect(() => { + if (!svgRef.current || projectedPoints.length === 0) return; + + const svg = d3.select(svgRef.current); + const containerWidth = containerRef.current?.clientWidth || 800; + const containerHeight = height; + + // Clear previous content + svg.selectAll('*').remove(); + + // Set dimensions + svg + .attr('width', containerWidth) + .attr('height', containerHeight) + .attr('viewBox', `0 0 ${containerWidth} ${containerHeight}`); + + // Create main group for zoom/pan + const g = svg.append('g'); + + // Setup zoom + const zoom = d3.zoom<SVGSVGElement, unknown>() + .scaleExtent([0.1, 10]) + .on('zoom', (event) => { + g.attr('transform', event.transform); + }); + + svg.call(zoom); + + // Scales + const xScale = d3.scaleLinear() + .domain([-1.2, 1.2]) + .range([50, containerWidth - 50]); + + const yScale = d3.scaleLinear() + .domain([-1.2, 1.2]) + .range([containerHeight - 50, 50]); + + // Grid + const gridGroup = g.append('g').attr('class', 'grid'); + + // Vertical grid lines + for (let x = -1; x <= 1; x += 0.5) { + gridGroup.append('line') + .attr('x1', xScale(x)) + .attr('y1', yScale(-1.2)) + .attr('x2', xScale(x)) + .attr('y2', yScale(1.2)) + .attr('stroke', '#e2e8f0') + .attr('stroke-width', 0.5); + } + + // Horizontal grid lines + for (let y = -1; y <= 1; y += 0.5) { + gridGroup.append('line') + .attr('x1', xScale(-1.2)) + .attr('y1', yScale(y)) + .attr('x2', xScale(1.2)) + .attr('y2', yScale(y)) + .attr('stroke', '#e2e8f0') + .attr('stroke-width', 0.5); + } + + // Axes + gridGroup.append('line') + .attr('x1', xScale(-1.2)) + .attr('y1', yScale(0)) + .attr('x2', xScale(1.2)) + .attr('y2', yScale(0)) + .attr('stroke', '#94a3b8') + .attr('stroke-width', 1); + + gridGroup.append('line') + .attr('x1', xScale(0)) + .attr('y1', yScale(-1.2)) + .attr('x2', xScale(0)) + .attr('y2', yScale(1.2)) + .attr('stroke', '#94a3b8') + .attr('stroke-width', 1); + + // Points + const pointsGroup = g.append('g').attr('class', 'points'); + + // Draw neighbor connections first (below points) + if (selectedIndex !== null) { + const selectedPoint = projectedPoints.find(p => p.originalIndex === selectedIndex); + if (selectedPoint) { + nearestNeighbors.forEach(neighbor => { + const neighborPoint = projectedPoints.find(p => p.originalIndex === neighbor.index); + if (neighborPoint) { + pointsGroup.append('line') + .attr('x1', xScale(selectedPoint.x)) + .attr('y1', yScale(selectedPoint.y)) + .attr('x2', xScale(neighborPoint.x)) + .attr('y2', yScale(neighborPoint.y)) + .attr('stroke', '#6366f1') + .attr('stroke-width', 1) + .attr('stroke-opacity', 0.3) + .attr('stroke-dasharray', '4,2'); + } + }); + } + } + + // Draw points + const circles = pointsGroup.selectAll('circle') + .data(projectedPoints) + .join('circle') + .attr('cx', d => xScale(d.x)) + .attr('cy', d => yScale(d.y)) + .attr('r', d => { + if (d.originalIndex === selectedIndex) return 8; + if (d.originalIndex === hoveredIndex) return 6; + if (nearestNeighbors.some(n => n.index === d.originalIndex)) return 5; + if (filteredIndices && !filteredIndices.includes(d.originalIndex)) return 2; + return 4; + }) + .attr('fill', d => getPointColor(d.originalIndex)) + .attr('fill-opacity', d => { + if (filteredIndices && !filteredIndices.includes(d.originalIndex)) return 0.1; + if (d.originalIndex === selectedIndex) return 1; + if (nearestNeighbors.some(n => n.index === d.originalIndex)) return 0.9; + return 0.7; + }) + .attr('stroke', d => d.originalIndex === selectedIndex ? '#1e293b' : 'none') + .attr('stroke-width', 2) + .attr('cursor', 'pointer') + .on('mouseenter', (_, d) => setHoveredIndex(d.originalIndex)) + .on('mouseleave', () => setHoveredIndex(null)) + .on('click', (_, d) => { + setSelectedIndex(prev => prev === d.originalIndex ? null : d.originalIndex); + }); + + // Labels + if (showLabels) { + const labelField = colorByField || 'id'; + + pointsGroup.selectAll('text') + .data(projectedPoints.filter((_, i) => i % Math.ceil(projectedPoints.length / 100) === 0)) + .join('text') + .attr('x', d => xScale(d.x) + 6) + .attr('y', d => yScale(d.y) + 3) + .attr('font-size', '10px') + .attr('fill', '#475569') + .text(d => { + const point = points[d.originalIndex]; + const value = labelField === 'id' ? point.id : point.payload[labelField]; + const str = String(value ?? ''); + return str.length > 15 ? str.slice(0, 12) + '...' : str; + }); + } + + // Tooltip + const tooltip = d3.select(containerRef.current) + .selectAll('.projector-tooltip') + .data([null]) + .join('div') + .attr('class', 'projector-tooltip') + .style('position', 'absolute') + .style('pointer-events', 'none') + .style('background', 'white') + .style('border', '1px solid #e2e8f0') + .style('border-radius', '4px') + .style('padding', '8px') + .style('font-size', '12px') + .style('box-shadow', '0 2px 4px rgba(0,0,0,0.1)') + .style('display', 'none') + .style('z-index', '100'); + + circles + .on('mouseenter', function(event, d) { + const point = points[d.originalIndex]; + tooltip + .style('display', 'block') + .style('left', `${event.offsetX + 10}px`) + .style('top', `${event.offsetY + 10}px`) + .html(` + <strong>ID:</strong> ${point.id}<br/> + ${colorByField ? `<strong>${colorByField}:</strong> ${point.payload[colorByField]}<br/>` : ''} + `); + }) + .on('mouseleave', () => { + tooltip.style('display', 'none'); + }); + + }, [projectedPoints, selectedIndex, hoveredIndex, nearestNeighbors, filteredIndices, + showLabels, colorByField, getPointColor, points, height]); + + // Three.js 3D visualization - Scene initialization (only runs when viewMode or projectedPoints change) + useEffect(() => { + if (viewMode !== '3d' || !threeContainerRef.current || projectedPoints.length === 0) { + // Cleanup if switching away from 3D + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + } + if (rendererRef.current && threeContainerRef.current) { + threeContainerRef.current.removeChild(rendererRef.current.domElement); + rendererRef.current.dispose(); + rendererRef.current = null; + } + return; + } + + const container = threeContainerRef.current; + const containerWidth = container.clientWidth || 800; + const containerHeight = container.clientHeight || 600; + + // Initialize scene + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0xfafafa); + sceneRef.current = scene; + + // Initialize camera + const camera = new THREE.PerspectiveCamera( + 60, + containerWidth / containerHeight, + 0.1, + 1000 + ); + camera.position.set(2, 2, 2); + camera.lookAt(0, 0, 0); + cameraRef.current = camera; + + // Initialize renderer + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(containerWidth, containerHeight); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + container.appendChild(renderer.domElement); + rendererRef.current = renderer; + + // Add orbit controls for rotation/zoom/pan + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.05; + controls.screenSpacePanning = true; + controls.minDistance = 0.5; + controls.maxDistance = 20; + controlsRef.current = controls; + + // Add grid helper + const gridHelper = new THREE.GridHelper(2, 10, 0xcccccc, 0xe0e0e0); + scene.add(gridHelper); + + // Add axes helper + const axesHelper = new THREE.AxesHelper(1.2); + scene.add(axesHelper); + + // Create point cloud geometry + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(projectedPoints.length * 3); + const colors = new Float32Array(projectedPoints.length * 3); + const sizes = new Float32Array(projectedPoints.length); + + projectedPoints.forEach((point, i) => { + positions[i * 3] = point.x; + positions[i * 3 + 1] = point.y; + positions[i * 3 + 2] = point.z ?? 0; + + // Get color for point (initial color without selection) + const color = new THREE.Color(getPointColor(point.originalIndex)); + colors[i * 3] = color.r; + colors[i * 3 + 1] = color.g; + colors[i * 3 + 2] = color.b; + + // Initial size (will be updated by selection effect) + sizes[i] = 4; + }); + + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + + // Create shader material for variable-sized points + const material = new THREE.ShaderMaterial({ + uniforms: { + pointTexture: { value: createCircleTexture() } + }, + vertexShader: ` + attribute float size; + varying vec3 vColor; + void main() { + vColor = color; + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = size * (20.0 / -mvPosition.z); + gl_Position = projectionMatrix * mvPosition; + } + `, + fragmentShader: ` + uniform sampler2D pointTexture; + varying vec3 vColor; + void main() { + gl_FragColor = vec4(vColor, 1.0); + gl_FragColor = gl_FragColor * texture2D(pointTexture, gl_PointCoord); + if (gl_FragColor.a < 0.3) discard; + } + `, + vertexColors: true, + transparent: true, + }); + + const pointCloud = new THREE.Points(geometry, material); + scene.add(pointCloud); + pointCloudRef.current = pointCloud; + + // Add ambient light + const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); + scene.add(ambientLight); + + // Add directional light + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); + directionalLight.position.set(5, 10, 5); + scene.add(directionalLight); + + // Add raycaster for point selection + const raycaster = new THREE.Raycaster(); + raycaster.params.Points = { threshold: 0.1 }; + const mouse = new THREE.Vector2(); + + const onMouseClick = (event: MouseEvent) => { + const rect = container.getBoundingClientRect(); + mouse.x = ((event.clientX - rect.left) / containerWidth) * 2 - 1; + mouse.y = -((event.clientY - rect.top) / containerHeight) * 2 + 1; + + raycaster.setFromCamera(mouse, camera); + const intersects = raycaster.intersectObject(pointCloud); + + if (intersects.length > 0) { + const index = intersects[0].index; + if (index !== undefined) { + const originalIndex = projectedPoints[index].originalIndex; + setSelectedIndex(prev => prev === originalIndex ? null : originalIndex); + } + } + }; + + container.addEventListener('click', onMouseClick); + + // Animation loop + const animate = () => { + animationFrameRef.current = requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + }; + animate(); + + // Handle resize + const handleResize = () => { + if (!container || !renderer || !camera) return; + const width = container.clientWidth; + const height = container.clientHeight; + camera.aspect = width / height; + camera.updateProjectionMatrix(); + renderer.setSize(width, height); + }; + + window.addEventListener('resize', handleResize); + + // Cleanup + return () => { + window.removeEventListener('resize', handleResize); + container.removeEventListener('click', onMouseClick); + + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + if (renderer) { + container.removeChild(renderer.domElement); + renderer.dispose(); + } + if (geometry) geometry.dispose(); + if (material) material.dispose(); + }; + }, [viewMode, projectedPoints, getPointColor]); + + // Update point sizes and colors when selection changes (without recreating the scene) + useEffect(() => { + if (viewMode !== '3d' || !pointCloudRef.current || projectedPoints.length === 0) { + return; + } + + const geometry = pointCloudRef.current.geometry; + const sizes = geometry.getAttribute('size') as THREE.BufferAttribute; + const colors = geometry.getAttribute('color') as THREE.BufferAttribute; + + if (!sizes || !colors) return; + + projectedPoints.forEach((point, i) => { + // Update color + const color = new THREE.Color(getPointColor(point.originalIndex)); + colors.setXYZ(i, color.r, color.g, color.b); + + // Update size based on selection + if (point.originalIndex === selectedIndex) { + sizes.setX(i, 12); + } else if (nearestNeighbors.some(n => n.index === point.originalIndex)) { + sizes.setX(i, 8); + } else if (filteredIndices && !filteredIndices.includes(point.originalIndex)) { + sizes.setX(i, 2); + } else { + sizes.setX(i, 4); + } + }); + + sizes.needsUpdate = true; + colors.needsUpdate = true; + }, [viewMode, projectedPoints, selectedIndex, nearestNeighbors, filteredIndices, getPointColor]); + + // Helper function to create circle texture for points + function createCircleTexture(): THREE.Texture { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext('2d')!; + + const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); + gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); + gradient.addColorStop(0.3, 'rgba(255, 255, 255, 1)'); + gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 64, 64); + + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + return texture; + } + + return ( + <div className="embedding-projector" ref={containerRef}> + {/* Header */} + <div className="projector-header"> + <h3>{t('title')}</h3> + <p className="projector-description">{t('description')}</p> + <div className="projector-stats"> + <span>{points.length.toLocaleString()} {t('pointsLoaded')}</span> + {points[0] && <span>{points[0].vector.length} {t('dimensions')}</span>} + </div> + </div> + + {/* Controls */} + <div className="projector-controls"> + {/* Left: Method & View */} + <div className="control-section"> + <div className="control-group"> + <label>{t('projectionMethod')}</label> + <div className="button-group"> + <button + className={projectionMethod === 'pca' ? 'active' : ''} + onClick={() => setProjectionMethod('pca')} + > + PCA + </button> + <button + className={projectionMethod === 'umap' ? 'active' : ''} + onClick={() => setProjectionMethod('umap')} + > + UMAP + </button> + <button + className={projectionMethod === 'tsne' ? 'active' : ''} + onClick={() => setProjectionMethod('tsne')} + > + t-SNE + </button> + </div> + </div> + + <div className="control-group"> + <label>{t('viewMode')}</label> + <div className="button-group"> + <button + className={viewMode === '2d' ? 'active' : ''} + onClick={() => setViewMode('2d')} + > + 2D + </button> + <button + className={viewMode === '3d' ? 'active' : ''} + onClick={() => setViewMode('3d')} + > + 3D + </button> + </div> + </div> + </div> + + {/* Method-specific parameters */} + <div className="control-section parameters"> + {projectionMethod === 'umap' && ( + <> + <div className="control-group small"> + <label>{t('umapNeighbors')}</label> + <input + type="range" + min="5" + max="50" + value={umapNeighbors} + onChange={(e) => setUmapNeighbors(Number(e.target.value))} + /> + <span className="value">{umapNeighbors}</span> + </div> + <div className="control-group small"> + <label>{t('minDist')}</label> + <input + type="range" + min="0" + max="1" + step="0.05" + value={umapMinDist} + onChange={(e) => setUmapMinDist(Number(e.target.value))} + /> + <span className="value">{umapMinDist.toFixed(2)}</span> + </div> + </> + )} + + {projectionMethod === 'tsne' && ( + <> + <div className="control-group small"> + <label>{t('perplexity')}</label> + <input + type="range" + min="5" + max="50" + value={tsnePerplexity} + onChange={(e) => setTsnePerplexity(Number(e.target.value))} + /> + <span className="value">{tsnePerplexity}</span> + </div> + <div className="control-group small"> + <label>{t('learningRate')}</label> + <input + type="range" + min="10" + max="500" + value={tsneLearningRate} + onChange={(e) => setTsneLearningRate(Number(e.target.value))} + /> + <span className="value">{tsneLearningRate}</span> + </div> + </> + )} + + {projectionMethod === 'pca' && pcaVariance.length > 0 && ( + <div className="pca-variance"> + <span>{t('variance')}: </span> + {pcaVariance.map((v, i) => ( + <span key={i} className="variance-badge"> + PC{i + 1}: {v.toFixed(1)}% + </span> + ))} + </div> + )} + </div> + + {/* Right: Color & Search */} + <div className="control-section"> + <div className="control-group"> + <label>{t('colorBy')}</label> + <select + value={colorByField} + onChange={(e) => setColorByField(e.target.value)} + > + <option value="">{t('noField')}</option> + {payloadFields.map(field => ( + <option key={field} value={field}>{field}</option> + ))} + </select> + </div> + + <div className="control-group"> + <label>{t('search')}</label> + <input + type="text" + placeholder={t('search')} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="search-input" + /> + </div> + + <div className="control-group checkbox"> + <label> + <input + type="checkbox" + checked={showLabels} + onChange={(e) => setShowLabels(e.target.checked)} + /> + {t('showLabels')} + </label> + </div> + </div> + + {/* Run button */} + <div className="control-section actions"> + <button + className="run-button" + onClick={runProjection} + disabled={isComputing || points.length === 0} + > + {isComputing ? ( + <> + {t('computing')} + {computeProgress && ` (${computeProgress.iteration}/${computeProgress.total})`} + </> + ) : ( + t('run') + )} + </button> + </div> + </div> + + {/* Main visualization area */} + <div className="projector-main"> + {/* Canvas */} + <div className="projector-canvas"> + {projectedPoints.length > 0 ? ( + viewMode === '2d' ? ( + <svg ref={svgRef} /> + ) : ( + <div ref={threeContainerRef} className="three-container" /> + ) + ) : ( + <div className="projector-placeholder"> + <p> + {points.length > 0 + ? 'Click "Run" to compute projection' + : 'Load vectors to visualize' + } + </p> + </div> + )} + </div> + + {/* Sidebar */} + <div className="projector-sidebar"> + {/* Legend */} + {colorByField && fieldCategories.length > 0 && ( + <div className="projector-legend"> + <h4>{colorByField}</h4> + <div className="legend-items"> + {fieldCategories.map((value, idx) => ( + <div key={value} className="legend-item"> + <span + className="legend-color" + style={{ backgroundColor: COLORS[idx % COLORS.length] }} + /> + <span className="legend-label"> + {value.length > 20 ? value.slice(0, 17) + '...' : value} + </span> + </div> + ))} + </div> + </div> + )} + + {/* Selected point details */} + {selectedIndex !== null && points[selectedIndex] && ( + <div className="projector-details"> + <h4>{t('selectedPoint')}</h4> + <div className="detail-item"> + <strong>ID:</strong> {String(points[selectedIndex].id)} + </div> + <div className="detail-payload"> + {Object.entries(points[selectedIndex].payload).slice(0, 10).map(([key, value]) => ( + <div key={key} className="payload-item"> + <span className="payload-key">{key}:</span> + <span className="payload-value"> + {String(value).length > 50 + ? String(value).slice(0, 47) + '...' + : String(value) + } + </span> + </div> + ))} + </div> + + {/* Nearest neighbors */} + {nearestNeighbors.length > 0 && ( + <div className="nearest-neighbors"> + <h4>{t('nearestNeighbors')}</h4> + <div className="neighbor-control"> + <label>{t('neighbors')}:</label> + <input + type="range" + min="1" + max="50" + value={neighborCount} + onChange={(e) => setNeighborCount(Number(e.target.value))} + /> + <span>{neighborCount}</span> + </div> + <div className="neighbor-list"> + {nearestNeighbors.slice(0, 10).map(neighbor => ( + <div + key={neighbor.index} + className="neighbor-item" + onClick={() => setSelectedIndex(neighbor.index)} + > + <span className="neighbor-id"> + {String(points[neighbor.index].id).slice(0, 20)} + </span> + <span className="neighbor-distance"> + {neighbor.distance.toFixed(4)} + </span> + </div> + ))} + </div> + </div> + )} + </div> + )} + </div> + </div> + </div> + ); +} + +export default EmbeddingProjector; diff --git a/frontend/src/components/database/QdrantPanel.tsx b/frontend/src/components/database/QdrantPanel.tsx index 5b37bfd7be..0066b00a50 100644 --- a/frontend/src/components/database/QdrantPanel.tsx +++ b/frontend/src/components/database/QdrantPanel.tsx @@ -18,6 +18,8 @@ import { useState, useCallback, useMemo } from 'react'; import { useQdrant } from '@/hooks/useQdrant'; import type { QdrantCollection, QdrantPoint } from '@/hooks/useQdrant'; import { useLanguage } from '@/contexts/LanguageContext'; +import { EmbeddingProjector } from './EmbeddingProjector'; +import type { EmbeddingPoint } from './EmbeddingProjector'; interface QdrantPanelProps { compact?: boolean; @@ -85,65 +87,6 @@ const TEXT = { dimensions: { nl: 'dimensies', en: 'dimensions' }, }; -// Simple PCA implementation for initial visualization (UMAP/t-SNE would require additional libraries) -function computePCA(vectors: number[][], dimensions: number = 2): number[][] { - if (vectors.length === 0) return []; - - const n = vectors.length; - const d = vectors[0].length; - - // Center the data - const means = new Array(d).fill(0); - for (const vec of vectors) { - for (let i = 0; i < d; i++) { - means[i] += vec[i] / n; - } - } - - const centered = vectors.map(vec => vec.map((v, i) => v - means[i])); - - // Power iteration for top eigenvectors (simplified PCA) - const result: number[][] = []; - - for (const vec of centered) { - // Simple projection using first `dimensions` components - const projected = vec.slice(0, dimensions); - result.push(projected); - } - - // Normalize to [-1, 1] range - const mins = new Array(dimensions).fill(Infinity); - const maxs = new Array(dimensions).fill(-Infinity); - - for (const point of result) { - for (let i = 0; i < dimensions; i++) { - mins[i] = Math.min(mins[i], point[i]); - maxs[i] = Math.max(maxs[i], point[i]); - } - } - - return result.map(point => - point.map((v, i) => { - const range = maxs[i] - mins[i]; - return range > 0 ? ((v - mins[i]) / range) * 2 - 1 : 0; - }) - ); -} - -// Color palette for categorical data -const COLORS = [ - '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', - '#f43f5e', '#ef4444', '#f97316', '#f59e0b', '#eab308', - '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4', - '#0ea5e9', '#3b82f6', '#6366f1', -]; - -// Get color for a category -function getCategoryColor(value: string, categories: string[]): string { - const index = categories.indexOf(value); - return COLORS[index % COLORS.length]; -} - // Get status icon const getStatusIcon = (status: string): string => { switch (status) { @@ -188,14 +131,21 @@ export function QdrantPanel({ compact = false }: QdrantPanelProps) { const [isLoadingPoints, setIsLoadingPoints] = useState(false); const [nextOffset, setNextOffset] = useState<string | number | null>(null); - // Visualization state - const [projectedPoints, setProjectedPoints] = useState<number[][]>([]); - const [projectionMethod, setProjectionMethod] = useState<'pca' | 'umap' | 'tsne'>('pca'); - const [colorByField, setColorByField] = useState<string>(''); - const [selectedPointIndex, setSelectedPointIndex] = useState<number | null>(null); - const [isComputing, setIsComputing] = useState(false); + // Visualization state (simplified - most logic moved to EmbeddingProjector) + const [colorByField, _setColorByField] = useState<string>(''); + const [_selectedPointIndex, setSelectedPointIndex] = useState<number | null>(null); - // Extract unique payload fields for color coding + // Convert QdrantPoints to EmbeddingPoints for the projector + const embeddingPoints: EmbeddingPoint[] = useMemo(() => { + return points.map(p => ({ + id: p.id, + vector: p.vector, + payload: p.payload, + })); + }, [points]); + + // Extract unique payload fields for color coding (available for future use) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const payloadFields = useMemo(() => { const fields = new Set<string>(); for (const point of points) { @@ -205,19 +155,9 @@ export function QdrantPanel({ compact = false }: QdrantPanelProps) { } return Array.from(fields).sort(); }, [points]); - - // Get unique values for selected field (for color legend) - const fieldCategories = useMemo(() => { - if (!colorByField) return []; - const values = new Set<string>(); - for (const point of points) { - const value = point.payload[colorByField]; - if (value !== undefined && value !== null) { - values.add(String(value)); - } - } - return Array.from(values).slice(0, 20); // Limit to 20 categories - }, [points, colorByField]); + + // Suppress unused variable warning - available for future features + void payloadFields; // Load points from selected collection const loadPoints = useCallback(async (append: boolean = false) => { @@ -244,33 +184,10 @@ export function QdrantPanel({ compact = false }: QdrantPanelProps) { } }, [selectedCollection, nextOffset, scrollPoints]); - // Compute 2D projection - const computeProjection = useCallback(() => { - if (points.length === 0) return; - - setIsComputing(true); - - // Use setTimeout to allow UI to update - setTimeout(() => { - const vectors = points.map(p => p.vector).filter(v => v.length > 0); - - if (vectors.length === 0) { - setIsComputing(false); - return; - } - - // For now, use PCA. UMAP/t-SNE would require additional libraries - const projected = computePCA(vectors, 2); - setProjectedPoints(projected); - setIsComputing(false); - }, 100); - }, [points]); - // Select a collection const selectCollection = useCallback(async (collection: QdrantCollection) => { setSelectedCollection(collection); setPoints([]); - setProjectedPoints([]); setSelectedPointIndex(null); setExplorerView('data'); }, []); @@ -280,7 +197,6 @@ export function QdrantPanel({ compact = false }: QdrantPanelProps) { setExplorerView('list'); setSelectedCollection(null); setPoints([]); - setProjectedPoints([]); setSelectedPointIndex(null); }; @@ -507,13 +423,8 @@ export function QdrantPanel({ compact = false }: QdrantPanelProps) { {activeTab === 'visualize' && ( <div className="visualization-panel"> - <div className="viz-header"> - <h3>{t('embeddingVisualization')}</h3> - <p>{t('visualizationDescription')}</p> - </div> - - {/* Controls */} - <div className="viz-controls"> + {/* Collection selector for visualization */} + <div className="viz-collection-selector"> <div className="control-group"> <label>{t('collections')}:</label> <select @@ -531,125 +442,61 @@ export function QdrantPanel({ compact = false }: QdrantPanelProps) { ))} </select> </div> - - <div className="control-group"> - <label>{t('projectionMethod')}:</label> - <select - value={projectionMethod} - onChange={(e) => setProjectionMethod(e.target.value as 'pca' | 'umap' | 'tsne')} - > - <option value="pca">{t('pca')}</option> - <option value="umap" disabled>{t('umap')} (coming soon)</option> - <option value="tsne" disabled>{t('tsne')} (coming soon)</option> - </select> - </div> - - <div className="control-group"> - <label>{t('colorBy')}:</label> - <select - value={colorByField} - onChange={(e) => setColorByField(e.target.value)} - > - <option value="">{t('noField')}</option> - {payloadFields.map(field => ( - <option key={field} value={field}>{field}</option> - ))} - </select> - </div> - - <div className="control-actions"> - <button - className="primary-button" - onClick={() => loadPoints(false)} - disabled={!selectedCollection || isLoadingPoints} - > - {isLoadingPoints ? t('loadingVectors') : t('loadVectors')} - </button> - <button - className="secondary-button" - onClick={computeProjection} - disabled={points.length === 0 || isComputing} - > - {isComputing ? t('computing') : t('computeProjection')} - </button> - </div> - </div> - - {/* Visualization Canvas */} - <div className="viz-container"> - {projectedPoints.length > 0 ? ( - <div className="scatter-plot"> - <svg viewBox="-1.2 -1.2 2.4 2.4" className="viz-svg"> - {/* Grid lines */} - <line x1="-1" y1="0" x2="1" y2="0" stroke="#e2e8f0" strokeWidth="0.01" /> - <line x1="0" y1="-1" x2="0" y2="1" stroke="#e2e8f0" strokeWidth="0.01" /> - - {/* Points */} - {projectedPoints.map((point, idx) => { - const payload = points[idx]?.payload || {}; - const colorValue = colorByField ? String(payload[colorByField] ?? '') : ''; - const color = colorByField && colorValue - ? getCategoryColor(colorValue, fieldCategories) - : '#6366f1'; - const isSelected = selectedPointIndex === idx; - - return ( - <circle - key={idx} - cx={point[0]} - cy={-point[1]} // Flip Y axis - r={isSelected ? 0.04 : 0.02} - fill={color} - opacity={isSelected ? 1 : 0.7} - stroke={isSelected ? '#1e293b' : 'none'} - strokeWidth="0.01" - className="viz-point" - onClick={() => setSelectedPointIndex(idx)} - /> - ); - })} - </svg> - - {/* Legend */} - {colorByField && fieldCategories.length > 0 && ( - <div className="viz-legend"> - <strong>{colorByField}:</strong> - {fieldCategories.map((value, idx) => ( - <span key={value} className="legend-item"> - <span - className="legend-color" - style={{ backgroundColor: COLORS[idx % COLORS.length] }} - /> - {value.length > 20 ? value.slice(0, 17) + '...' : value} - </span> - ))} - </div> + + {selectedCollection && ( + <div className="viz-load-controls"> + <button + className="primary-button" + onClick={() => loadPoints(false)} + disabled={isLoadingPoints} + > + {isLoadingPoints ? t('loadingVectors') : t('loadVectors')} + </button> + {points.length > 0 && ( + <span className="loaded-count"> + {points.length} {t('vectorsLoaded')} + </span> + )} + {nextOffset !== null && points.length > 0 && ( + <button + className="secondary-button" + onClick={() => loadPoints(true)} + disabled={isLoadingPoints} + > + Load more + </button> )} - </div> - ) : ( - <div className="viz-placeholder"> - <p>{points.length > 0 - ? t('computeProjection') - : selectedCollection - ? t('loadVectors') - : t('selectCollection') - }</p> </div> )} </div> - {/* Selected Point Details */} - {selectedPointIndex !== null && points[selectedPointIndex] && ( - <div className="selected-point-details"> - <h4>{t('selectedPoint')}</h4> - <div className="point-info"> - <div className="point-id"> - <strong>{t('id')}:</strong> {String(points[selectedPointIndex].id)} - </div> - <div className="point-payload"> - <strong>{t('payload')}:</strong> - <pre>{JSON.stringify(points[selectedPointIndex].payload, null, 2)}</pre> - </div> + {/* Embedding Projector */} + {points.length > 0 ? ( + <EmbeddingProjector + points={embeddingPoints} + onPointSelect={(point) => { + if (point) { + const idx = points.findIndex(p => p.id === point.id); + setSelectedPointIndex(idx >= 0 ? idx : null); + } else { + setSelectedPointIndex(null); + } + }} + colorByField={colorByField} + height={600} + /> + ) : ( + <div className="viz-placeholder"> + <div className="placeholder-content"> + <span className="placeholder-icon">⚡</span> + <h3>{t('embeddingVisualization')}</h3> + <p>{t('visualizationDescription')}</p> + <p className="placeholder-hint"> + {selectedCollection + ? t('loadVectors') + : t('selectCollection') + } + </p> </div> </div> )} diff --git a/frontend/src/components/database/index.ts b/frontend/src/components/database/index.ts index 5f8397beca..63837d802c 100644 --- a/frontend/src/components/database/index.ts +++ b/frontend/src/components/database/index.ts @@ -10,3 +10,5 @@ export { PostgreSQLPanel } from './PostgreSQLPanel'; export { TypeDBPanel } from './TypeDBPanel'; export { OxigraphPanel } from './OxigraphPanel'; export { QdrantPanel } from './QdrantPanel'; +export { EmbeddingProjector } from './EmbeddingProjector'; +export type { EmbeddingPoint, ProjectedPoint, ProjectionMethod, ViewMode } from './EmbeddingProjector'; diff --git a/frontend/src/components/gesprek/GesprekBarChart.tsx b/frontend/src/components/gesprek/GesprekBarChart.tsx new file mode 100644 index 0000000000..a2eaad3387 --- /dev/null +++ b/frontend/src/components/gesprek/GesprekBarChart.tsx @@ -0,0 +1,434 @@ +/** + * GesprekBarChart.tsx - D3 Bar Chart Visualization for Gesprek Page + * + * Features: + * - Horizontal and vertical bar charts + * - Animated transitions + * - Hover interactions with tooltips + * - Responsive sizing + * - NDE house style colors + * + * Uses D3.js v7 with React 19 + */ + +import React, { useRef, useEffect, useState, useMemo } from 'react'; +import * as d3 from 'd3'; +import type { ChartData } from '../../hooks/useMultiDatabaseRAG'; + +// NDE House Style Colors +const COLORS = { + primary: '#154273', + secondary: '#2E5A8B', + accent: '#3B82F6', + background: '#f8fafc', + text: '#1e293b', + textLight: '#64748b', + grid: '#e2e8f0', + barDefault: '#154273', + barHover: '#3B82F6', +}; + +// Default color palette for multiple datasets +const COLOR_PALETTE = [ + '#154273', // Primary blue + '#ef4444', // Red (museum) + '#10b981', // Green (archive) + '#f59e0b', // Amber (gallery) + '#8b5cf6', // Purple (university) + '#ec4899', // Pink + '#06b6d4', // Cyan + '#84cc16', // Lime +]; + +export interface GesprekBarChartProps { + data: ChartData; + width?: number; + height?: number; + orientation?: 'vertical' | 'horizontal'; + showGrid?: boolean; + showValues?: boolean; + animate?: boolean; + onBarClick?: (label: string, value: number, datasetIndex: number) => void; + language?: 'nl' | 'en'; + className?: string; + title?: string; +} + +interface TooltipState { + visible: boolean; + x: number; + y: number; + label: string; + value: number; + dataset: string; +} + +export const GesprekBarChart: React.FC<GesprekBarChartProps> = ({ + data, + width = 500, + height = 300, + orientation = 'vertical', + showGrid = true, + showValues = true, + animate = true, + onBarClick, + language = 'nl', + className, + title, +}) => { + const svgRef = useRef<SVGSVGElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [tooltip, setTooltip] = useState<TooltipState>({ + visible: false, + x: 0, + y: 0, + label: '', + value: 0, + dataset: '', + }); + + // Margins for axes + const margin = useMemo(() => ({ + top: title ? 40 : 20, + right: 20, + bottom: 60, + left: orientation === 'horizontal' ? 120 : 50, + }), [orientation, title]); + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // Main D3 visualization + useEffect(() => { + if (!svgRef.current || !data.labels.length || !data.datasets.length) return; + + // Clear previous content + d3.select(svgRef.current).selectAll('*').remove(); + + const svg = d3.select(svgRef.current) + .attr('width', width) + .attr('height', height) + .attr('viewBox', [0, 0, width, height]); + + // Create main group with margins + const g = svg.append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + // Add title if provided + if (title) { + svg.append('text') + .attr('x', width / 2) + .attr('y', 20) + .attr('text-anchor', 'middle') + .attr('font-size', '14px') + .attr('font-weight', '600') + .attr('fill', COLORS.text) + .text(title); + } + + // Calculate max value across all datasets + const maxValue = d3.max(data.datasets.flatMap(d => d.data)) || 0; + + // Create scales based on orientation + const xScale = orientation === 'vertical' + ? d3.scaleBand() + .domain(data.labels) + .range([0, innerWidth]) + .padding(0.2) + : d3.scaleLinear() + .domain([0, maxValue * 1.1]) + .range([0, innerWidth]) + .nice(); + + const yScale = orientation === 'vertical' + ? d3.scaleLinear() + .domain([0, maxValue * 1.1]) + .range([innerHeight, 0]) + .nice() + : d3.scaleBand() + .domain(data.labels) + .range([0, innerHeight]) + .padding(0.2); + + // Add grid lines + if (showGrid) { + const gridGroup = g.append('g').attr('class', 'grid'); + + if (orientation === 'vertical') { + gridGroup.selectAll('.grid-line') + .data((yScale as d3.ScaleLinear<number, number>).ticks(5)) + .join('line') + .attr('class', 'grid-line') + .attr('x1', 0) + .attr('x2', innerWidth) + .attr('y1', d => (yScale as d3.ScaleLinear<number, number>)(d)) + .attr('y2', d => (yScale as d3.ScaleLinear<number, number>)(d)) + .attr('stroke', COLORS.grid) + .attr('stroke-dasharray', '3,3'); + } else { + gridGroup.selectAll('.grid-line') + .data((xScale as d3.ScaleLinear<number, number>).ticks(5)) + .join('line') + .attr('class', 'grid-line') + .attr('x1', d => (xScale as d3.ScaleLinear<number, number>)(d)) + .attr('x2', d => (xScale as d3.ScaleLinear<number, number>)(d)) + .attr('y1', 0) + .attr('y2', innerHeight) + .attr('stroke', COLORS.grid) + .attr('stroke-dasharray', '3,3'); + } + } + + // Add axes + const xAxis = orientation === 'vertical' + ? d3.axisBottom(xScale as d3.ScaleBand<string>) + : d3.axisBottom(xScale as d3.ScaleLinear<number, number>).ticks(5); + + const yAxis = orientation === 'vertical' + ? d3.axisLeft(yScale as d3.ScaleLinear<number, number>).ticks(5) + : d3.axisLeft(yScale as d3.ScaleBand<string>); + + g.append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0,${innerHeight})`) + .call(xAxis) + .selectAll('text') + .attr('font-size', '11px') + .attr('fill', COLORS.textLight) + .attr('transform', orientation === 'vertical' ? 'rotate(-45)' : null) + .attr('text-anchor', orientation === 'vertical' ? 'end' : 'middle') + .attr('dx', orientation === 'vertical' ? '-0.5em' : null) + .attr('dy', orientation === 'vertical' ? '0.5em' : '1em'); + + g.append('g') + .attr('class', 'y-axis') + .call(yAxis) + .selectAll('text') + .attr('font-size', '11px') + .attr('fill', COLORS.textLight); + + // Draw bars for each dataset + const numDatasets = data.datasets.length; + const bandWidth = orientation === 'vertical' + ? (xScale as d3.ScaleBand<string>).bandwidth() + : (yScale as d3.ScaleBand<string>).bandwidth(); + const barWidth = bandWidth / numDatasets; + + data.datasets.forEach((dataset, datasetIndex) => { + const barGroup = g.append('g') + .attr('class', `bars-${datasetIndex}`); + + const color = Array.isArray(dataset.backgroundColor) + ? dataset.backgroundColor + : dataset.backgroundColor || COLOR_PALETTE[datasetIndex % COLOR_PALETTE.length]; + + barGroup.selectAll('rect') + .data(dataset.data.map((value, i) => ({ + value, + label: data.labels[i], + index: i, + }))) + .join('rect') + .attr('x', d => { + if (orientation === 'vertical') { + const bandX = (xScale as d3.ScaleBand<string>)(d.label) || 0; + return bandX + barWidth * datasetIndex; + } + return 0; + }) + .attr('y', d => { + if (orientation === 'vertical') { + return animate ? innerHeight : (yScale as d3.ScaleLinear<number, number>)(d.value); + } + const bandY = (yScale as d3.ScaleBand<string>)(d.label) || 0; + return bandY + barWidth * datasetIndex; + }) + .attr('width', orientation === 'vertical' ? barWidth - 2 : (animate ? 0 : (xScale as d3.ScaleLinear<number, number>)(0))) + .attr('height', orientation === 'vertical' + ? (animate ? 0 : innerHeight - (yScale as d3.ScaleLinear<number, number>)(0)) + : barWidth - 2) + .attr('fill', d => Array.isArray(color) ? color[d.index % color.length] : color) + .attr('rx', 2) + .attr('ry', 2) + .style('cursor', 'pointer') + .on('mouseenter', function(event, d) { + d3.select(this) + .transition() + .duration(200) + .attr('opacity', 0.8); + + const [x, y] = d3.pointer(event, containerRef.current); + setTooltip({ + visible: true, + x: x + 10, + y: y - 10, + label: d.label, + value: d.value, + dataset: dataset.label, + }); + }) + .on('mouseleave', function() { + d3.select(this) + .transition() + .duration(200) + .attr('opacity', 1); + + setTooltip(prev => ({ ...prev, visible: false })); + }) + .on('click', (_event, d) => { + if (onBarClick) { + onBarClick(d.label, d.value, datasetIndex); + } + }); + + // Animate bars + if (animate) { + barGroup.selectAll<SVGRectElement, { value: number; label: string; index: number }>('rect') + .transition() + .duration(800) + .delay((_d, i) => i * 50) + .ease(d3.easeCubicOut) + .attr('y', d => { + if (orientation === 'vertical') { + return (yScale as d3.ScaleLinear<number, number>)(d.value); + } + const bandY = (yScale as d3.ScaleBand<string>)(d.label) || 0; + return bandY + barWidth * datasetIndex; + }) + .attr('width', d => { + if (orientation === 'horizontal') { + return (xScale as d3.ScaleLinear<number, number>)(d.value); + } + return barWidth - 2; + }) + .attr('height', d => { + if (orientation === 'vertical') { + return innerHeight - (yScale as d3.ScaleLinear<number, number>)(d.value); + } + return barWidth - 2; + }); + } + + // Add value labels + if (showValues) { + barGroup.selectAll('.value-label') + .data(dataset.data.map((value, i) => ({ + value, + label: data.labels[i], + index: i, + }))) + .join('text') + .attr('class', 'value-label') + .attr('x', d => { + if (orientation === 'vertical') { + const bandX = (xScale as d3.ScaleBand<string>)(d.label) || 0; + return bandX + barWidth * datasetIndex + (barWidth - 2) / 2; + } + return (xScale as d3.ScaleLinear<number, number>)(d.value) + 5; + }) + .attr('y', d => { + if (orientation === 'vertical') { + return (yScale as d3.ScaleLinear<number, number>)(d.value) - 5; + } + const bandY = (yScale as d3.ScaleBand<string>)(d.label) || 0; + return bandY + barWidth * datasetIndex + (barWidth - 2) / 2; + }) + .attr('text-anchor', orientation === 'vertical' ? 'middle' : 'start') + .attr('dominant-baseline', orientation === 'horizontal' ? 'middle' : 'auto') + .attr('font-size', '10px') + .attr('font-weight', '500') + .attr('fill', COLORS.text) + .attr('opacity', animate ? 0 : 1) + .text(d => d.value.toLocaleString(language === 'nl' ? 'nl-NL' : 'en-US')); + + // Animate value labels + if (animate) { + barGroup.selectAll('.value-label') + .transition() + .duration(800) + .delay((_d, i) => i * 50 + 400) + .attr('opacity', 1); + } + } + }); + + // Add legend if multiple datasets + if (numDatasets > 1) { + const legend = svg.append('g') + .attr('class', 'legend') + .attr('transform', `translate(${margin.left}, ${height - 15})`); + + data.datasets.forEach((dataset, i) => { + const legendItem = legend.append('g') + .attr('transform', `translate(${i * 100}, 0)`); + + legendItem.append('rect') + .attr('width', 12) + .attr('height', 12) + .attr('rx', 2) + .attr('fill', Array.isArray(dataset.backgroundColor) + ? dataset.backgroundColor[0] + : dataset.backgroundColor || COLOR_PALETTE[i % COLOR_PALETTE.length]); + + legendItem.append('text') + .attr('x', 16) + .attr('y', 10) + .attr('font-size', '11px') + .attr('fill', COLORS.textLight) + .text(dataset.label); + }); + } + + }, [data, width, height, orientation, showGrid, showValues, animate, innerWidth, innerHeight, margin, language, onBarClick]); + + // Empty state + if (!data.labels.length || !data.datasets.length) { + return ( + <div className={`gesprek-bar-chart gesprek-bar-chart--empty ${className || ''}`}> + <div className="gesprek-bar-chart__empty"> + <span>{language === 'nl' ? 'Geen gegevens beschikbaar' : 'No data available'}</span> + </div> + </div> + ); + } + + return ( + <div + ref={containerRef} + className={`gesprek-bar-chart ${className || ''}`} + style={{ position: 'relative' }} + > + <svg ref={svgRef} /> + + {/* Tooltip */} + {tooltip.visible && ( + <div + className="gesprek-bar-chart__tooltip" + style={{ + position: 'absolute', + left: tooltip.x, + top: tooltip.y, + pointerEvents: 'none', + zIndex: 100, + backgroundColor: 'white', + border: '1px solid #e2e8f0', + borderRadius: '4px', + padding: '8px 12px', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + fontSize: '12px', + }} + > + <div style={{ fontWeight: '600', color: COLORS.text }}>{tooltip.label}</div> + {data.datasets.length > 1 && ( + <div style={{ color: COLORS.textLight, fontSize: '11px' }}>{tooltip.dataset}</div> + )} + <div style={{ color: COLORS.primary, fontWeight: '500', marginTop: '4px' }}> + {tooltip.value.toLocaleString(language === 'nl' ? 'nl-NL' : 'en-US')} + </div> + </div> + )} + </div> + ); +}; + +export default GesprekBarChart; diff --git a/frontend/src/components/gesprek/GesprekGeoMap.tsx b/frontend/src/components/gesprek/GesprekGeoMap.tsx new file mode 100644 index 0000000000..66918f84ea --- /dev/null +++ b/frontend/src/components/gesprek/GesprekGeoMap.tsx @@ -0,0 +1,433 @@ +/** + * GesprekGeoMap.tsx - D3 Geographic Map Visualization for Gesprek Page + * + * Features: + * - Netherlands province boundaries + * - Bubble map with institution markers + * - Clustering for dense areas + * - Zoom and pan + * - Tooltips with institution details + * + * Uses D3.js v7 with React 19 + */ + +import React, { useRef, useEffect, useState, useCallback } from 'react'; +import * as d3 from 'd3'; +import type { GeoCoordinate, InstitutionData } from '../../hooks/useMultiDatabaseRAG'; + +// NDE House Style Colors +const COLORS = { + primary: '#154273', + secondary: '#2E5A8B', + accent: '#3B82F6', + background: '#f8fafc', + water: '#e0f2fe', + land: '#f1f5f9', + border: '#cbd5e1', + marker: '#154273', + markerHover: '#3B82F6', + markerSelected: '#ef4444', + text: '#1e293b', +}; + +// Institution type to color mapping +const TYPE_COLORS: Record<string, string> = { + museum: '#ef4444', + library: '#3b82f6', + archive: '#10b981', + gallery: '#f59e0b', + university: '#8b5cf6', + default: '#154273', +}; + +export interface GesprekGeoMapProps { + coordinates: GeoCoordinate[]; + width?: number; + height?: number; + onMarkerClick?: (data: InstitutionData) => void; + onMarkerHover?: (data: InstitutionData | null) => void; + selectedId?: string | null; + language?: 'nl' | 'en'; + showClustering?: boolean; + className?: string; +} + +interface TooltipState { + visible: boolean; + x: number; + y: number; + data: InstitutionData | null; +} + +/** + * Get marker color based on institution type + */ +function getMarkerColor(type?: string): string { + if (!type) return TYPE_COLORS.default; + const normalizedType = type.toLowerCase(); + + if (normalizedType.includes('museum')) return TYPE_COLORS.museum; + if (normalizedType.includes('bibliotheek') || normalizedType.includes('library')) return TYPE_COLORS.library; + if (normalizedType.includes('archief') || normalizedType.includes('archive')) return TYPE_COLORS.archive; + if (normalizedType.includes('galerie') || normalizedType.includes('gallery')) return TYPE_COLORS.gallery; + if (normalizedType.includes('universiteit') || normalizedType.includes('university')) return TYPE_COLORS.university; + + return TYPE_COLORS.default; +} + +/** + * Calculate marker radius based on data (e.g., rating or reviews) + */ +function getMarkerRadius(data?: InstitutionData): number { + if (!data) return 6; + + // Scale based on reviews if available + if (data.reviews && data.reviews > 0) { + return Math.max(4, Math.min(20, 4 + Math.log10(data.reviews + 1) * 5)); + } + + // Scale based on rating if available + if (data.rating && data.rating > 0) { + return Math.max(4, Math.min(15, 4 + data.rating * 2)); + } + + return 6; +} + +export const GesprekGeoMap: React.FC<GesprekGeoMapProps> = ({ + coordinates, + width = 600, + height = 500, + onMarkerClick, + onMarkerHover, + selectedId, + language = 'nl', + showClustering = true, + className, +}) => { + const svgRef = useRef<SVGSVGElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [tooltip, setTooltip] = useState<TooltipState>({ + visible: false, + x: 0, + y: 0, + data: null, + }); + const [geoData, setGeoData] = useState<GeoJSON.FeatureCollection | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + // Load Netherlands GeoJSON + useEffect(() => { + const loadGeoJSON = async () => { + try { + setLoading(true); + const response = await fetch('/data/netherlands_provinces.geojson'); + if (!response.ok) throw new Error('Failed to load map data'); + const data = await response.json(); + setGeoData(data); + setError(null); + } catch (err) { + console.error('Failed to load GeoJSON:', err); + setError(language === 'nl' ? 'Kaartgegevens laden mislukt' : 'Failed to load map data'); + } finally { + setLoading(false); + } + }; + + loadGeoJSON(); + }, [language]); + + // Fit to bounds function + const fitToBounds = useCallback(() => { + if (!svgRef.current) return; + window.dispatchEvent(new CustomEvent('gesprek-map-fit')); + }, []); + + // Main D3 visualization + useEffect(() => { + if (!svgRef.current || !geoData || loading) return; + + // Clear previous content + d3.select(svgRef.current).selectAll('*').remove(); + + const svg = d3.select(svgRef.current) + .attr('width', width) + .attr('height', height) + .attr('viewBox', [0, 0, width, height]); + + // Create container group for zoom + const g = svg.append('g'); + + // Setup projection centered on Netherlands + const projection = d3.geoMercator() + .center([5.5, 52.2]) // Center of Netherlands + .scale(width * 15) + .translate([width / 2, height / 2]); + + const pathGenerator = d3.geoPath().projection(projection); + + // Setup zoom behavior + const zoom = d3.zoom<SVGSVGElement, unknown>() + .scaleExtent([0.5, 20]) + .on('zoom', (event) => { + g.attr('transform', event.transform); + }); + + svg.call(zoom); + + // Listen for fit-to-bounds event + const handleFit = () => { + if (coordinates.length === 0) return; + + // Calculate bounds of all markers + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + coordinates.forEach(coord => { + const [x, y] = projection([coord.lng, coord.lat]) || [0, 0]; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + }); + + const padding = 50; + const boundsWidth = maxX - minX + padding * 2; + const boundsHeight = maxY - minY + padding * 2; + const scale = Math.min(width / boundsWidth, height / boundsHeight, 4); + const translateX = (width - boundsWidth * scale) / 2 - (minX - padding) * scale; + const translateY = (height - boundsHeight * scale) / 2 - (minY - padding) * scale; + + svg.transition() + .duration(750) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .call(zoom.transform as any, d3.zoomIdentity.translate(translateX, translateY).scale(scale)); + }; + + window.addEventListener('gesprek-map-fit', handleFit); + + // Draw water background + svg.insert('rect', ':first-child') + .attr('width', width) + .attr('height', height) + .attr('fill', COLORS.water); + + // Draw province boundaries + g.append('g') + .attr('class', 'provinces') + .selectAll('path') + .data(geoData.features) + .join('path') + .attr('d', pathGenerator as never) + .attr('fill', COLORS.land) + .attr('stroke', COLORS.border) + .attr('stroke-width', 1) + .attr('stroke-linejoin', 'round'); + + // Draw markers + const markersGroup = g.append('g').attr('class', 'markers'); + + // Filter out invalid coordinates + const validCoords = coordinates.filter(c => + c.lat && c.lng && + !isNaN(c.lat) && !isNaN(c.lng) && + c.lat >= 50 && c.lat <= 54 && // Netherlands bounds + c.lng >= 3 && c.lng <= 8 + ); + + // Simple clustering for dense areas (if enabled) + let displayCoords = validCoords; + if (showClustering && validCoords.length > 100) { + // Use quadtree-based clustering + const clustered: GeoCoordinate[] = []; + const cellSize = 0.1; // degrees + const cells = new Map<string, GeoCoordinate[]>(); + + validCoords.forEach(coord => { + const key = `${Math.floor(coord.lat / cellSize)},${Math.floor(coord.lng / cellSize)}`; + if (!cells.has(key)) cells.set(key, []); + cells.get(key)!.push(coord); + }); + + cells.forEach(cellCoords => { + if (cellCoords.length === 1) { + clustered.push(cellCoords[0]); + } else { + // Create cluster centroid + const avgLat = cellCoords.reduce((s, c) => s + c.lat, 0) / cellCoords.length; + const avgLng = cellCoords.reduce((s, c) => s + c.lng, 0) / cellCoords.length; + clustered.push({ + lat: avgLat, + lng: avgLng, + label: `${cellCoords.length} ${language === 'nl' ? 'instellingen' : 'institutions'}`, + data: { + id: `cluster-${avgLat}-${avgLng}`, + name: `${cellCoords.length} ${language === 'nl' ? 'instellingen' : 'institutions'}`, + reviews: cellCoords.length * 10, // Use for sizing + }, + }); + } + }); + + displayCoords = clustered; + } + + // Draw marker circles + markersGroup.selectAll('circle') + .data(displayCoords) + .join('circle') + .attr('cx', d => { + const [x] = projection([d.lng, d.lat]) || [0, 0]; + return x; + }) + .attr('cy', d => { + const [, y] = projection([d.lng, d.lat]) || [0, 0]; + return y; + }) + .attr('r', d => getMarkerRadius(d.data)) + .attr('fill', d => getMarkerColor(d.type)) + .attr('fill-opacity', 0.7) + .attr('stroke', d => selectedId && d.data?.id === selectedId ? COLORS.markerSelected : '#fff') + .attr('stroke-width', d => selectedId && d.data?.id === selectedId ? 3 : 1.5) + .style('cursor', 'pointer') + .on('mouseenter', function(event, d) { + d3.select(this) + .transition() + .duration(200) + .attr('r', getMarkerRadius(d.data) * 1.3) + .attr('fill-opacity', 1); + + const [x, y] = d3.pointer(event, containerRef.current); + setTooltip({ + visible: true, + x: x + 10, + y: y - 10, + data: d.data || null, + }); + + if (onMarkerHover && d.data) { + onMarkerHover(d.data); + } + }) + .on('mouseleave', function(_event, d) { + d3.select(this) + .transition() + .duration(200) + .attr('r', getMarkerRadius(d.data)) + .attr('fill-opacity', 0.7); + + setTooltip(prev => ({ ...prev, visible: false })); + + if (onMarkerHover) { + onMarkerHover(null); + } + }) + .on('click', (_event, d) => { + if (onMarkerClick && d.data) { + onMarkerClick(d.data); + } + }); + + // Cleanup + return () => { + window.removeEventListener('gesprek-map-fit', handleFit); + }; + }, [geoData, coordinates, width, height, loading, selectedId, showClustering, language, onMarkerClick, onMarkerHover]); + + if (loading) { + return ( + <div className={`gesprek-geo-map gesprek-geo-map--loading ${className || ''}`}> + <div className="gesprek-geo-map__loading"> + <div className="gesprek-geo-map__spinner" /> + <span>{language === 'nl' ? 'Kaart laden...' : 'Loading map...'}</span> + </div> + </div> + ); + } + + if (error) { + return ( + <div className={`gesprek-geo-map gesprek-geo-map--error ${className || ''}`}> + <span>{error}</span> + </div> + ); + } + + return ( + <div + ref={containerRef} + className={`gesprek-geo-map ${className || ''}`} + style={{ position: 'relative' }} + > + <svg ref={svgRef} /> + + {/* Tooltip */} + {tooltip.visible && tooltip.data && ( + <div + className="gesprek-geo-map__tooltip" + style={{ + position: 'absolute', + left: tooltip.x, + top: tooltip.y, + pointerEvents: 'none', + zIndex: 100, + }} + > + <div className="gesprek-geo-map__tooltip-name">{tooltip.data.name}</div> + {tooltip.data.type && ( + <div className="gesprek-geo-map__tooltip-type">{tooltip.data.type}</div> + )} + {tooltip.data.city && ( + <div className="gesprek-geo-map__tooltip-city">{tooltip.data.city}</div> + )} + {tooltip.data.rating && tooltip.data.rating > 0 && ( + <div className="gesprek-geo-map__tooltip-rating"> + {'★'.repeat(Math.round(tooltip.data.rating))} + {' '} + {tooltip.data.rating.toFixed(1)} + </div> + )} + </div> + )} + + {/* Map controls */} + <div className="gesprek-geo-map__controls"> + <button + className="gesprek-geo-map__control-btn" + onClick={fitToBounds} + title={language === 'nl' ? 'Zoom naar markers' : 'Fit to markers'} + > + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /> + </svg> + </button> + </div> + + {/* Legend */} + <div className="gesprek-geo-map__legend"> + <div className="gesprek-geo-map__legend-item"> + <span className="gesprek-geo-map__legend-dot" style={{ backgroundColor: TYPE_COLORS.museum }} /> + <span>Museum</span> + </div> + <div className="gesprek-geo-map__legend-item"> + <span className="gesprek-geo-map__legend-dot" style={{ backgroundColor: TYPE_COLORS.library }} /> + <span>{language === 'nl' ? 'Bibliotheek' : 'Library'}</span> + </div> + <div className="gesprek-geo-map__legend-item"> + <span className="gesprek-geo-map__legend-dot" style={{ backgroundColor: TYPE_COLORS.archive }} /> + <span>{language === 'nl' ? 'Archief' : 'Archive'}</span> + </div> + <div className="gesprek-geo-map__legend-item"> + <span className="gesprek-geo-map__legend-dot" style={{ backgroundColor: TYPE_COLORS.gallery }} /> + <span>{language === 'nl' ? 'Galerie' : 'Gallery'}</span> + </div> + </div> + + {/* Stats */} + <div className="gesprek-geo-map__stats"> + {coordinates.length} {language === 'nl' ? 'locaties' : 'locations'} + </div> + </div> + ); +}; + +export default GesprekGeoMap; diff --git a/frontend/src/components/gesprek/GesprekNetworkGraph.tsx b/frontend/src/components/gesprek/GesprekNetworkGraph.tsx new file mode 100644 index 0000000000..1664a37dd8 --- /dev/null +++ b/frontend/src/components/gesprek/GesprekNetworkGraph.tsx @@ -0,0 +1,549 @@ +/** + * GesprekNetworkGraph.tsx - D3 Force-Directed Network Graph for Gesprek Page + * + * Features: + * - Force-directed layout with collision detection + * - Node dragging + * - Zoom and pan + * - Node and edge highlighting on hover + * - Tooltips with entity details + * - Edge labels + * + * Uses D3.js v7 with React 19 + */ + +import React, { useRef, useEffect, useState, useMemo } from 'react'; +import * as d3 from 'd3'; +import type { GraphVisualizationData } from '../../hooks/useMultiDatabaseRAG'; + +// NDE House Style Colors +const COLORS = { + primary: '#154273', + secondary: '#2E5A8B', + accent: '#3B82F6', + background: '#f8fafc', + text: '#1e293b', + textLight: '#64748b', + link: '#94a3b8', + linkHighlight: '#3B82F6', + nodeStroke: '#fff', +}; + +// Node type to color mapping +const NODE_TYPE_COLORS: Record<string, string> = { + institution: '#154273', + museum: '#ef4444', + library: '#3b82f6', + archive: '#10b981', + gallery: '#f59e0b', + person: '#8b5cf6', + collection: '#ec4899', + event: '#06b6d4', + place: '#84cc16', + organization: '#154273', + default: '#154273', +}; + +export interface GesprekNetworkGraphProps { + data: GraphVisualizationData; + width?: number; + height?: number; + onNodeClick?: (nodeId: string, nodeData: GraphVisualizationData['nodes'][0]) => void; + onNodeHover?: (nodeId: string | null, nodeData: GraphVisualizationData['nodes'][0] | null) => void; + selectedNodeId?: string | null; + language?: 'nl' | 'en'; + showLabels?: boolean; + showEdgeLabels?: boolean; + className?: string; +} + +interface SimulationNode extends d3.SimulationNodeDatum { + id: string; + label: string; + type: string; + attributes?: Record<string, unknown>; +} + +interface SimulationLink extends d3.SimulationLinkDatum<SimulationNode> { + id: string; + label: string; + type?: string; +} + +interface TooltipState { + visible: boolean; + x: number; + y: number; + node: GraphVisualizationData['nodes'][0] | null; +} + +/** + * Get node color based on type + */ +function getNodeColor(type?: string): string { + if (!type) return NODE_TYPE_COLORS.default; + const normalizedType = type.toLowerCase(); + + for (const [key, color] of Object.entries(NODE_TYPE_COLORS)) { + if (normalizedType.includes(key)) { + return color; + } + } + + return NODE_TYPE_COLORS.default; +} + +/** + * Get node radius based on connections + */ +function getNodeRadius(id: string, edges: GraphVisualizationData['edges']): number { + const connections = edges.filter(e => e.source === id || e.target === id).length; + return Math.max(8, Math.min(25, 8 + connections * 2)); +} + +export const GesprekNetworkGraph: React.FC<GesprekNetworkGraphProps> = ({ + data, + width = 600, + height = 400, + onNodeClick, + onNodeHover, + selectedNodeId, + language = 'nl', + showLabels = true, + showEdgeLabels = false, + className, +}) => { + const svgRef = useRef<SVGSVGElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const simulationRef = useRef<d3.Simulation<SimulationNode, SimulationLink> | null>(null); + const [tooltip, setTooltip] = useState<TooltipState>({ + visible: false, + x: 0, + y: 0, + node: null, + }); + + // Process data for D3 simulation + const { nodes, links } = useMemo(() => { + const simNodes: SimulationNode[] = data.nodes.map(node => ({ + ...node, + x: undefined, + y: undefined, + })); + + const simLinks: SimulationLink[] = data.edges.map(edge => ({ + ...edge, + source: edge.source, + target: edge.target, + })); + + return { nodes: simNodes, links: simLinks }; + }, [data]); + + // Main D3 visualization + useEffect(() => { + if (!svgRef.current || nodes.length === 0) return; + + // Clear previous content + d3.select(svgRef.current).selectAll('*').remove(); + + const svg = d3.select(svgRef.current) + .attr('width', width) + .attr('height', height) + .attr('viewBox', [0, 0, width, height]); + + // Create container group for zoom + const g = svg.append('g'); + + // Create arrow marker for directed edges + svg.append('defs') + .append('marker') + .attr('id', 'arrowhead') + .attr('viewBox', '0 -5 10 10') + .attr('refX', 20) + .attr('refY', 0) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5') + .attr('fill', COLORS.link); + + // Setup zoom behavior + const zoom = d3.zoom<SVGSVGElement, unknown>() + .scaleExtent([0.2, 4]) + .on('zoom', (event) => { + g.attr('transform', event.transform); + }); + + svg.call(zoom); + + // Create force simulation + const simulation = d3.forceSimulation<SimulationNode>(nodes) + .force('link', d3.forceLink<SimulationNode, SimulationLink>(links) + .id(d => d.id) + .distance(100) + .strength(0.5)) + .force('charge', d3.forceManyBody().strength(-300)) + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('collision', d3.forceCollide<SimulationNode>().radius(d => getNodeRadius(d.id, data.edges) + 10)); + + simulationRef.current = simulation; + + // Draw links + const linksGroup = g.append('g').attr('class', 'links'); + + const link = linksGroup.selectAll('.link') + .data(links) + .join('g') + .attr('class', 'link'); + + const linkLine = link.append('line') + .attr('stroke', COLORS.link) + .attr('stroke-width', 1.5) + .attr('stroke-opacity', 0.6) + .attr('marker-end', 'url(#arrowhead)'); + + // Edge labels + let linkLabels: d3.Selection<SVGTextElement, SimulationLink, SVGGElement, unknown> | null = null; + if (showEdgeLabels) { + linkLabels = link.append('text') + .attr('class', 'link-label') + .attr('font-size', '9px') + .attr('fill', COLORS.textLight) + .attr('text-anchor', 'middle') + .attr('dy', '-5') + .text(d => d.label || ''); + } + + // Draw nodes + const nodesGroup = g.append('g').attr('class', 'nodes'); + + const node = nodesGroup.selectAll('.node') + .data(nodes) + .join('g') + .attr('class', 'node') + .style('cursor', 'pointer'); + + // Node circles + const nodeCircle = node.append('circle') + .attr('r', d => getNodeRadius(d.id, data.edges)) + .attr('fill', d => getNodeColor(d.type)) + .attr('stroke', d => selectedNodeId === d.id ? COLORS.accent : COLORS.nodeStroke) + .attr('stroke-width', d => selectedNodeId === d.id ? 3 : 2) + .attr('stroke-opacity', 0.9); + + // Node labels + if (showLabels) { + node.append('text') + .attr('class', 'node-label') + .attr('dy', d => getNodeRadius(d.id, data.edges) + 12) + .attr('text-anchor', 'middle') + .attr('font-size', '10px') + .attr('font-weight', '500') + .attr('fill', COLORS.text) + .text(d => { + const maxLength = 15; + return d.label.length > maxLength + ? d.label.substring(0, maxLength) + '...' + : d.label; + }); + } + + // Drag behavior + const drag = d3.drag<SVGGElement, SimulationNode>() + .on('start', (event, d) => { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + }) + .on('drag', (event, d) => { + d.fx = event.x; + d.fy = event.y; + }) + .on('end', (event, d) => { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + node.call(drag as any); + + // Hover interactions + node + .on('mouseenter', function(event, d) { + // Highlight node + d3.select(this).select('circle') + .transition() + .duration(200) + .attr('r', getNodeRadius(d.id, data.edges) * 1.2) + .attr('stroke', COLORS.accent) + .attr('stroke-width', 3); + + // Highlight connected links + linkLine + .attr('stroke', l => + (l.source as SimulationNode).id === d.id || (l.target as SimulationNode).id === d.id + ? COLORS.linkHighlight + : COLORS.link + ) + .attr('stroke-width', l => + (l.source as SimulationNode).id === d.id || (l.target as SimulationNode).id === d.id + ? 2.5 + : 1.5 + ) + .attr('stroke-opacity', l => + (l.source as SimulationNode).id === d.id || (l.target as SimulationNode).id === d.id + ? 1 + : 0.3 + ); + + // Fade unconnected nodes + nodeCircle + .attr('opacity', n => { + if (n.id === d.id) return 1; + const connected = links.some(l => + ((l.source as SimulationNode).id === d.id && (l.target as SimulationNode).id === n.id) || + ((l.target as SimulationNode).id === d.id && (l.source as SimulationNode).id === n.id) + ); + return connected ? 1 : 0.3; + }); + + // Show tooltip + const [x, y] = d3.pointer(event, containerRef.current); + setTooltip({ + visible: true, + x: x + 10, + y: y - 10, + node: data.nodes.find(n => n.id === d.id) || null, + }); + + if (onNodeHover) { + onNodeHover(d.id, data.nodes.find(n => n.id === d.id) || null); + } + }) + .on('mouseleave', function(_event, d) { + // Reset node + d3.select(this).select('circle') + .transition() + .duration(200) + .attr('r', getNodeRadius(d.id, data.edges)) + .attr('stroke', selectedNodeId === d.id ? COLORS.accent : COLORS.nodeStroke) + .attr('stroke-width', selectedNodeId === d.id ? 3 : 2); + + // Reset links + linkLine + .attr('stroke', COLORS.link) + .attr('stroke-width', 1.5) + .attr('stroke-opacity', 0.6); + + // Reset nodes + nodeCircle.attr('opacity', 1); + + setTooltip(prev => ({ ...prev, visible: false })); + + if (onNodeHover) { + onNodeHover(null, null); + } + }) + .on('click', (_event, d) => { + if (onNodeClick) { + onNodeClick(d.id, data.nodes.find(n => n.id === d.id)!); + } + }); + + // Update positions on simulation tick + simulation.on('tick', () => { + linkLine + .attr('x1', d => (d.source as SimulationNode).x || 0) + .attr('y1', d => (d.source as SimulationNode).y || 0) + .attr('x2', d => (d.target as SimulationNode).x || 0) + .attr('y2', d => (d.target as SimulationNode).y || 0); + + if (linkLabels) { + linkLabels + .attr('x', d => (((d.source as SimulationNode).x || 0) + ((d.target as SimulationNode).x || 0)) / 2) + .attr('y', d => (((d.source as SimulationNode).y || 0) + ((d.target as SimulationNode).y || 0)) / 2); + } + + node.attr('transform', d => `translate(${d.x || 0},${d.y || 0})`); + }); + + // Fit to bounds function + const handleFit = () => { + const bounds = g.node()?.getBBox(); + if (!bounds) return; + + const padding = 50; + const fullWidth = bounds.width + padding * 2; + const fullHeight = bounds.height + padding * 2; + const scale = Math.min(width / fullWidth, height / fullHeight, 1.5); + const translateX = (width - bounds.width * scale) / 2 - bounds.x * scale; + const translateY = (height - bounds.height * scale) / 2 - bounds.y * scale; + + svg.transition() + .duration(750) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .call(zoom.transform as any, d3.zoomIdentity.translate(translateX, translateY).scale(scale)); + }; + + window.addEventListener('gesprek-network-fit', handleFit); + + // Initial fit after simulation stabilizes + simulation.on('end', () => { + setTimeout(handleFit, 100); + }); + + // Cleanup + return () => { + simulation.stop(); + window.removeEventListener('gesprek-network-fit', handleFit); + }; + }, [data, nodes, links, width, height, selectedNodeId, showLabels, showEdgeLabels, onNodeClick, onNodeHover]); + + // Control handlers + const handleFitToBounds = () => { + window.dispatchEvent(new CustomEvent('gesprek-network-fit')); + }; + + const handleRestartSimulation = () => { + if (simulationRef.current) { + simulationRef.current.alpha(1).restart(); + } + }; + + // Empty state + if (nodes.length === 0) { + return ( + <div className={`gesprek-network-graph gesprek-network-graph--empty ${className || ''}`}> + <div className="gesprek-network-graph__empty"> + <span>{language === 'nl' ? 'Geen netwerkgegevens beschikbaar' : 'No network data available'}</span> + </div> + </div> + ); + } + + return ( + <div + ref={containerRef} + className={`gesprek-network-graph ${className || ''}`} + style={{ position: 'relative' }} + > + <svg ref={svgRef} /> + + {/* Tooltip */} + {tooltip.visible && tooltip.node && ( + <div + className="gesprek-network-graph__tooltip" + style={{ + position: 'absolute', + left: Math.min(tooltip.x, width - 180), + top: Math.max(tooltip.y, 10), + pointerEvents: 'none', + zIndex: 100, + backgroundColor: 'white', + border: '1px solid #e2e8f0', + borderRadius: '4px', + padding: '8px 12px', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + fontSize: '12px', + maxWidth: '180px', + }} + > + <div style={{ fontWeight: '600', color: COLORS.text }}>{tooltip.node.label}</div> + {tooltip.node.type && ( + <div style={{ + display: 'inline-block', + marginTop: '4px', + padding: '2px 6px', + fontSize: '10px', + borderRadius: '3px', + backgroundColor: getNodeColor(tooltip.node.type) + '20', + color: getNodeColor(tooltip.node.type), + }}> + {tooltip.node.type} + </div> + )} + {tooltip.node.attributes && Object.keys(tooltip.node.attributes).length > 0 && ( + <div style={{ marginTop: '6px', fontSize: '11px', color: COLORS.textLight }}> + {Object.entries(tooltip.node.attributes).slice(0, 3).map(([key, value]) => ( + <div key={key}> + <span style={{ fontWeight: '500' }}>{key}:</span> {String(value)} + </div> + ))} + </div> + )} + </div> + )} + + {/* Controls */} + <div + className="gesprek-network-graph__controls" + style={{ + position: 'absolute', + top: '5px', + right: '5px', + display: 'flex', + gap: '4px', + }} + > + <button + onClick={handleFitToBounds} + title={language === 'nl' ? 'Zoom aanpassen' : 'Fit to view'} + style={{ + padding: '4px 8px', + fontSize: '11px', + backgroundColor: 'white', + border: '1px solid #e2e8f0', + borderRadius: '4px', + cursor: 'pointer', + color: COLORS.textLight, + }} + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" /> + </svg> + </button> + <button + onClick={handleRestartSimulation} + title={language === 'nl' ? 'Herstart simulatie' : 'Restart simulation'} + style={{ + padding: '4px 8px', + fontSize: '11px', + backgroundColor: 'white', + border: '1px solid #e2e8f0', + borderRadius: '4px', + cursor: 'pointer', + color: COLORS.textLight, + }} + > + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M1 4v6h6M23 20v-6h-6" /> + <path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" /> + </svg> + </button> + </div> + + {/* Legend */} + <div + className="gesprek-network-graph__legend" + style={{ + position: 'absolute', + bottom: '5px', + left: '5px', + display: 'flex', + gap: '12px', + fontSize: '10px', + color: COLORS.textLight, + }} + > + <span>{nodes.length} {language === 'nl' ? 'knopen' : 'nodes'}</span> + <span>•</span> + <span>{links.length} {language === 'nl' ? 'verbindingen' : 'edges'}</span> + </div> + </div> + ); +}; + +export default GesprekNetworkGraph; diff --git a/frontend/src/components/gesprek/GesprekTimeline.tsx b/frontend/src/components/gesprek/GesprekTimeline.tsx new file mode 100644 index 0000000000..a80625e572 --- /dev/null +++ b/frontend/src/components/gesprek/GesprekTimeline.tsx @@ -0,0 +1,497 @@ +/** + * GesprekTimeline.tsx - D3 Timeline Visualization for Gesprek Page + * + * Features: + * - Horizontal timeline with event markers + * - Zoom and pan support + * - Event clustering for dense periods + * - Tooltips with event details + * - Animated transitions + * + * Uses D3.js v7 with React 19 + */ + +import React, { useRef, useEffect, useState, useMemo } from 'react'; +import * as d3 from 'd3'; +import type { TimelineEvent } from '../../hooks/useMultiDatabaseRAG'; + +// NDE House Style Colors +const COLORS = { + primary: '#154273', + secondary: '#2E5A8B', + accent: '#3B82F6', + background: '#f8fafc', + text: '#1e293b', + textLight: '#64748b', + axis: '#94a3b8', + axisLine: '#cbd5e1', + marker: '#154273', + markerHover: '#3B82F6', +}; + +// Event type to color mapping +const EVENT_TYPE_COLORS: Record<string, string> = { + founding: '#10b981', // Green + closure: '#ef4444', // Red + merger: '#8b5cf6', // Purple + relocation: '#f59e0b', // Amber + name_change: '#06b6d4', // Cyan + acquisition: '#ec4899', // Pink + default: '#154273', // Primary blue +}; + +export interface GesprekTimelineProps { + events: TimelineEvent[]; + width?: number; + height?: number; + onEventClick?: (event: TimelineEvent) => void; + onEventHover?: (event: TimelineEvent | null) => void; + selectedEventId?: string | null; + language?: 'nl' | 'en'; + showLabels?: boolean; + className?: string; +} + +interface ParsedEvent extends TimelineEvent { + parsedDate: Date; +} + +interface TooltipState { + visible: boolean; + x: number; + y: number; + event: TimelineEvent | null; +} + +/** + * Parse various date formats to Date objects + */ +function parseDate(dateStr: string): Date | null { + if (!dateStr) return null; + + // Try ISO format first + let date = new Date(dateStr); + if (!isNaN(date.getTime())) return date; + + // Try year-only format + const yearMatch = dateStr.match(/^(\d{4})$/); + if (yearMatch) { + return new Date(parseInt(yearMatch[1]), 0, 1); + } + + // Try "Month YYYY" format + const monthYearMatch = dateStr.match(/^(\w+)\s+(\d{4})$/); + if (monthYearMatch) { + date = new Date(`${monthYearMatch[1]} 1, ${monthYearMatch[2]}`); + if (!isNaN(date.getTime())) return date; + } + + return null; +} + +/** + * Get marker color based on event type + */ +function getEventColor(type?: string): string { + if (!type) return EVENT_TYPE_COLORS.default; + const normalizedType = type.toLowerCase().replace(/[_-]/g, ''); + + for (const [key, color] of Object.entries(EVENT_TYPE_COLORS)) { + if (normalizedType.includes(key) || key.includes(normalizedType)) { + return color; + } + } + + return EVENT_TYPE_COLORS.default; +} + +/** + * Format date for display + */ +function formatDate(date: Date, language: 'nl' | 'en'): string { + return date.toLocaleDateString(language === 'nl' ? 'nl-NL' : 'en-US', { + year: 'numeric', + month: 'short', + day: date.getDate() !== 1 ? 'numeric' : undefined, + }); +} + +export const GesprekTimeline: React.FC<GesprekTimelineProps> = ({ + events, + width = 800, + height = 200, + onEventClick, + onEventHover, + selectedEventId, + language = 'nl', + showLabels = true, + className, +}) => { + const svgRef = useRef<SVGSVGElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const [tooltip, setTooltip] = useState<TooltipState>({ + visible: false, + x: 0, + y: 0, + event: null, + }); + + // Margins + const margin = useMemo(() => ({ + top: 30, + right: 30, + bottom: 40, + left: 30, + }), []); + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // Parse and filter events + const parsedEvents = useMemo<ParsedEvent[]>(() => { + return events + .map(event => ({ + ...event, + parsedDate: parseDate(event.date), + })) + .filter((event): event is ParsedEvent => event.parsedDate !== null) + .sort((a, b) => a.parsedDate.getTime() - b.parsedDate.getTime()); + }, [events]); + + // Main D3 visualization + useEffect(() => { + if (!svgRef.current || parsedEvents.length === 0) return; + + // Clear previous content + d3.select(svgRef.current).selectAll('*').remove(); + + const svg = d3.select(svgRef.current) + .attr('width', width) + .attr('height', height) + .attr('viewBox', [0, 0, width, height]); + + // Create clip path + svg.append('defs') + .append('clipPath') + .attr('id', 'timeline-clip') + .append('rect') + .attr('x', margin.left) + .attr('y', margin.top) + .attr('width', innerWidth) + .attr('height', innerHeight); + + // Create main group with margins + const g = svg.append('g') + .attr('transform', `translate(${margin.left},${margin.top})`); + + // Calculate time extent with padding + const timeExtent = d3.extent(parsedEvents, d => d.parsedDate) as [Date, Date]; + const timePadding = (timeExtent[1].getTime() - timeExtent[0].getTime()) * 0.05; + + const xScale = d3.scaleTime() + .domain([ + new Date(timeExtent[0].getTime() - timePadding), + new Date(timeExtent[1].getTime() + timePadding), + ]) + .range([0, innerWidth]); + + // Store original scale for zoom reset + const xScaleOriginal = xScale.copy(); + + // Create clipped group for content + const content = g.append('g') + .attr('clip-path', 'url(#timeline-clip)'); + + // Draw timeline axis line + content.append('line') + .attr('class', 'axis-line') + .attr('x1', 0) + .attr('x2', innerWidth) + .attr('y1', innerHeight / 2) + .attr('y2', innerHeight / 2) + .attr('stroke', COLORS.axisLine) + .attr('stroke-width', 2); + + // Draw axis + const xAxis = d3.axisBottom(xScale) + .ticks(Math.min(parsedEvents.length, 10)) + .tickFormat((d) => formatDate(d as Date, language)); + + const axisGroup = g.append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0,${innerHeight})`) + .call(xAxis); + + axisGroup.selectAll('text') + .attr('font-size', '10px') + .attr('fill', COLORS.textLight) + .attr('transform', 'rotate(-30)') + .attr('text-anchor', 'end') + .attr('dx', '-0.5em') + .attr('dy', '0.5em'); + + axisGroup.selectAll('line') + .attr('stroke', COLORS.axis); + + axisGroup.select('.domain') + .attr('stroke', COLORS.axis); + + // Draw event markers + const markersGroup = content.append('g').attr('class', 'markers'); + + const markers = markersGroup.selectAll('.event-marker') + .data(parsedEvents) + .join('g') + .attr('class', 'event-marker') + .attr('transform', d => `translate(${xScale(d.parsedDate)},${innerHeight / 2})`) + .style('cursor', 'pointer'); + + // Marker circles + markers.append('circle') + .attr('r', 0) + .attr('fill', d => getEventColor(d.type)) + .attr('stroke', '#fff') + .attr('stroke-width', 2) + .transition() + .duration(500) + .delay((_, i) => i * 50) + .attr('r', d => selectedEventId === d.date ? 10 : 7); + + // Marker connectors (vertical lines) + markers.append('line') + .attr('class', 'connector') + .attr('x1', 0) + .attr('x2', 0) + .attr('y1', 0) + .attr('y2', 0) + .attr('stroke', d => getEventColor(d.type)) + .attr('stroke-width', 1.5) + .attr('stroke-dasharray', '3,3') + .attr('opacity', 0) + .transition() + .duration(500) + .delay((_, i) => i * 50 + 300) + .attr('y2', (_, i) => (i % 2 === 0 ? -25 : 25)) + .attr('opacity', showLabels ? 0.7 : 0); + + // Event labels + if (showLabels) { + markers.append('text') + .attr('class', 'event-label') + .attr('x', 0) + .attr('y', (_, i) => (i % 2 === 0 ? -32 : 40)) + .attr('text-anchor', 'middle') + .attr('font-size', '10px') + .attr('fill', COLORS.text) + .attr('opacity', 0) + .text(d => { + // Truncate long labels + const maxLength = 20; + return d.label.length > maxLength + ? d.label.substring(0, maxLength) + '...' + : d.label; + }) + .transition() + .duration(500) + .delay((_, i) => i * 50 + 500) + .attr('opacity', 1); + } + + // Interaction handlers + markers + .on('mouseenter', function(event, d) { + d3.select(this).select('circle') + .transition() + .duration(200) + .attr('r', 10); + + const [x, y] = d3.pointer(event, containerRef.current); + setTooltip({ + visible: true, + x: x + 10, + y: y - 10, + event: d, + }); + + if (onEventHover) { + onEventHover(d); + } + }) + .on('mouseleave', function(_event, d) { + d3.select(this).select('circle') + .transition() + .duration(200) + .attr('r', selectedEventId === d.date ? 10 : 7); + + setTooltip(prev => ({ ...prev, visible: false })); + + if (onEventHover) { + onEventHover(null); + } + }) + .on('click', (_event, d) => { + if (onEventClick) { + onEventClick(d); + } + }); + + // Zoom behavior + const zoom = d3.zoom<SVGSVGElement, unknown>() + .scaleExtent([0.5, 10]) + .translateExtent([[-innerWidth, -innerHeight], [innerWidth * 2, innerHeight * 2]]) + .on('zoom', (event) => { + const newXScale = event.transform.rescaleX(xScaleOriginal); + + // Update axis + axisGroup.call(xAxis.scale(newXScale)); + + // Update markers + markers.attr('transform', d => + `translate(${newXScale(d.parsedDate)},${innerHeight / 2})` + ); + + // Update axis styling + axisGroup.selectAll('text') + .attr('font-size', '10px') + .attr('fill', COLORS.textLight) + .attr('transform', 'rotate(-30)') + .attr('text-anchor', 'end'); + }); + + svg.call(zoom); + + // Add zoom reset button handler + const handleReset = () => { + svg.transition() + .duration(750) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .call(zoom.transform as any, d3.zoomIdentity); + }; + + window.addEventListener('gesprek-timeline-reset', handleReset); + + return () => { + window.removeEventListener('gesprek-timeline-reset', handleReset); + }; + }, [parsedEvents, width, height, innerWidth, innerHeight, margin, selectedEventId, showLabels, language, onEventClick, onEventHover]); + + // Handle zoom reset + const handleResetZoom = () => { + window.dispatchEvent(new CustomEvent('gesprek-timeline-reset')); + }; + + // Empty state + if (parsedEvents.length === 0) { + return ( + <div className={`gesprek-timeline gesprek-timeline--empty ${className || ''}`}> + <div className="gesprek-timeline__empty"> + <span>{language === 'nl' ? 'Geen tijdlijngegevens beschikbaar' : 'No timeline data available'}</span> + </div> + </div> + ); + } + + return ( + <div + ref={containerRef} + className={`gesprek-timeline ${className || ''}`} + style={{ position: 'relative' }} + > + <svg ref={svgRef} /> + + {/* Tooltip */} + {tooltip.visible && tooltip.event && ( + <div + className="gesprek-timeline__tooltip" + style={{ + position: 'absolute', + left: Math.min(tooltip.x, width - 200), + top: Math.max(tooltip.y, 10), + pointerEvents: 'none', + zIndex: 100, + backgroundColor: 'white', + border: '1px solid #e2e8f0', + borderRadius: '4px', + padding: '8px 12px', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + fontSize: '12px', + maxWidth: '200px', + }} + > + <div style={{ fontWeight: '600', color: COLORS.text }}>{tooltip.event.label}</div> + <div style={{ color: COLORS.primary, fontSize: '11px', marginTop: '2px' }}> + {formatDate(parseDate(tooltip.event.date)!, language)} + </div> + {tooltip.event.type && ( + <div style={{ + display: 'inline-block', + marginTop: '4px', + padding: '2px 6px', + fontSize: '10px', + borderRadius: '3px', + backgroundColor: getEventColor(tooltip.event.type) + '20', + color: getEventColor(tooltip.event.type), + }}> + {tooltip.event.type} + </div> + )} + {tooltip.event.description && ( + <div style={{ color: COLORS.textLight, fontSize: '11px', marginTop: '4px' }}> + {tooltip.event.description} + </div> + )} + </div> + )} + + {/* Controls */} + <div + className="gesprek-timeline__controls" + style={{ + position: 'absolute', + top: '5px', + right: '5px', + display: 'flex', + gap: '4px', + }} + > + <button + onClick={handleResetZoom} + title={language === 'nl' ? 'Zoom resetten' : 'Reset zoom'} + style={{ + padding: '4px 8px', + fontSize: '11px', + backgroundColor: 'white', + border: '1px solid #e2e8f0', + borderRadius: '4px', + cursor: 'pointer', + color: COLORS.textLight, + }} + > + {language === 'nl' ? 'Reset' : 'Reset'} + </button> + </div> + + {/* Legend */} + <div + className="gesprek-timeline__legend" + style={{ + position: 'absolute', + bottom: '5px', + left: '5px', + display: 'flex', + gap: '12px', + fontSize: '10px', + color: COLORS.textLight, + }} + > + <span>{parsedEvents.length} {language === 'nl' ? 'gebeurtenissen' : 'events'}</span> + <span>•</span> + <span> + {formatDate(parsedEvents[0].parsedDate, language)} — {formatDate(parsedEvents[parsedEvents.length - 1].parsedDate, language)} + </span> + </div> + </div> + ); +}; + +export default GesprekTimeline; diff --git a/frontend/src/components/gesprek/index.ts b/frontend/src/components/gesprek/index.ts new file mode 100644 index 0000000000..0a82e27666 --- /dev/null +++ b/frontend/src/components/gesprek/index.ts @@ -0,0 +1,18 @@ +/** + * Gesprek Components Index + * + * D3 visualization components for the Gesprek (Conversation) page. + * All components follow NDE house style and support Dutch/English. + */ + +export { GesprekGeoMap } from './GesprekGeoMap'; +export type { GesprekGeoMapProps } from './GesprekGeoMap'; + +export { GesprekBarChart } from './GesprekBarChart'; +export type { GesprekBarChartProps } from './GesprekBarChart'; + +export { GesprekTimeline } from './GesprekTimeline'; +export type { GesprekTimelineProps } from './GesprekTimeline'; + +export { GesprekNetworkGraph } from './GesprekNetworkGraph'; +export type { GesprekNetworkGraphProps } from './GesprekNetworkGraph'; diff --git a/frontend/src/components/layout/Navigation.tsx b/frontend/src/components/layout/Navigation.tsx index 4e1b1052a7..68e3625bf0 100644 --- a/frontend/src/components/layout/Navigation.tsx +++ b/frontend/src/components/layout/Navigation.tsx @@ -178,6 +178,12 @@ export function Navigation() { > {t('overview')} </Link> + <Link + to="/gesprek" + className={`nav-link ${isActive('/gesprek') ? 'active' : ''}`} + > + {t('gesprek')} + </Link> <Link to="/settings" className={`nav-link ${isActive('/settings') ? 'active' : ''}`} @@ -268,6 +274,9 @@ export function Navigation() { <Link to="/overview" className={`nav-mobile-link ${isActive('/overview') ? 'active' : ''}`}> {t('overview')} </Link> + <Link to="/gesprek" className={`nav-mobile-link ${isActive('/gesprek') ? 'active' : ''}`}> + {t('gesprek')} + </Link> <Link to="/settings" className={`nav-mobile-link ${isActive('/settings') ? 'active' : ''}`}> {t('settings')} </Link> diff --git a/frontend/src/components/query/OntologyVisualizer.tsx b/frontend/src/components/query/OntologyVisualizer.tsx index 610dea8d8e..4ee80eb90e 100644 --- a/frontend/src/components/query/OntologyVisualizer.tsx +++ b/frontend/src/components/query/OntologyVisualizer.tsx @@ -6,10 +6,26 @@ */ import React, { useEffect, useRef, useState } from 'react'; -import mermaid from 'mermaid'; import type { SparqlClient } from '../../lib/sparql/client'; import './OntologyVisualizer.css'; +// Lazy load mermaid to avoid bundling issues +let mermaidInstance: typeof import('mermaid').default | null = null; +const getMermaid = async () => { + if (!mermaidInstance) { + const mod = await import('mermaid'); + mermaidInstance = mod.default; + mermaidInstance.initialize({ + startOnLoad: false, + theme: 'default', + securityLevel: 'loose', + fontFamily: 'Arial, sans-serif', + logLevel: 'error', + }); + } + return mermaidInstance; +}; + export interface OntologyVisualizerProps { /** Pre-loaded Mermaid diagram source */ mermaidSource?: string; @@ -35,16 +51,11 @@ export const OntologyVisualizer: React.FC<OntologyVisualizerProps> = ({ const [error, setError] = useState<string | null>(null); const [zoom, setZoom] = useState(1); const [generatedSource, setGeneratedSource] = useState<string | null>(null); + const [mermaidReady, setMermaidReady] = useState(false); - // Initialize Mermaid + // Initialize Mermaid (lazy loaded) useEffect(() => { - mermaid.initialize({ - startOnLoad: true, - theme: 'default', - securityLevel: 'loose', - fontFamily: 'Arial, sans-serif', - logLevel: 'error', - }); + getMermaid().then(() => setMermaidReady(true)).catch(console.error); }, []); // Generate Mermaid diagram from RDF data @@ -72,10 +83,11 @@ export const OntologyVisualizer: React.FC<OntologyVisualizerProps> = ({ // Render Mermaid diagram useEffect(() => { const source = mermaidSource || generatedSource; - if (!source || !containerRef.current) return; + if (!source || !containerRef.current || !mermaidReady) return; const renderDiagram = async () => { try { + const mermaid = await getMermaid(); const { svg } = await mermaid.render('mermaid-diagram', source); if (containerRef.current) { containerRef.current.innerHTML = svg; @@ -87,7 +99,7 @@ export const OntologyVisualizer: React.FC<OntologyVisualizerProps> = ({ }; renderDiagram(); - }, [mermaidSource, generatedSource]); + }, [mermaidSource, generatedSource, mermaidReady]); // Generate diagram when sparqlClient is provided useEffect(() => { diff --git a/frontend/src/components/uml/CustodianTypeIndicator.tsx b/frontend/src/components/uml/CustodianTypeIndicator.tsx new file mode 100644 index 0000000000..4492d8639e --- /dev/null +++ b/frontend/src/components/uml/CustodianTypeIndicator.tsx @@ -0,0 +1,477 @@ +/** + * CustodianTypeIndicator.tsx - Three.js 19-sided Polygon for Custodian Type Display + * + * Displays a 3D polygon badge showing which CustodianType(s) a schema element + * relates to. Uses the GLAMORCUBESFIXPHDNT taxonomy with color-coded polygons. + * + * The polygon has 19 sides - one for each letter in GLAMORCUBESFIXPHDNT: + * G-L-A-M-O-R-C-U-B-E-S-F-I-X-P-H-D-N-T + * + * Usage: + * - Pass one or more custodian type codes (e.g., ['M', 'A'] for Museum + Archive) + * - Component renders a rotating 3D 19-gon (enneadecagon) with the type letter(s) + * - Colors match the centralized custodian-types.ts configuration + */ + +import React, { useEffect, useRef, useMemo } from 'react'; +import * as THREE from 'three'; +import { + getCustodianTypeByCode, + type CustodianTypeCode, +} from '@/lib/custodian-types'; +import { useLanguage } from '@/contexts/LanguageContext'; + +// Total number of custodian types in GLAMORCUBESFIXPHDNT = 19 +const POLYGON_SIDES = 19; + +export interface CustodianTypeIndicatorProps { + /** Array of custodian type codes (single letters from GLAMORCUBESFIXPHDNT) */ + types: CustodianTypeCode[]; + /** Size of the indicator in pixels */ + size?: number; + /** Whether to animate the polygon rotation */ + animate?: boolean; + /** Show tooltip on hover */ + showTooltip?: boolean; + /** Custom CSS class */ + className?: string; +} + +/** + * Create a regular polygon geometry with the specified number of sides + * + * For GLAMORCUBESFIXPHDNT taxonomy, this creates a 19-sided polygon (enneadecagon) + * where each side represents one custodian type letter. + * + * @param sides - Number of sides (default: 19 for GLAMORCUBESFIXPHDNT) + * @param radius - Radius of the polygon + */ +function createPolygonGeometry(sides: number = POLYGON_SIDES, radius: number = 1): THREE.BufferGeometry { + console.log('[CustodianTypeIndicator] Creating polygon geometry with', sides, 'sides (one per GLAMORCUBESFIXPHDNT letter)'); + const shape = new THREE.Shape(); + + for (let i = 0; i <= sides; i++) { + const angle = (i / sides) * Math.PI * 2 - Math.PI / 2; // Start from top + const x = Math.cos(angle) * radius; + const y = Math.sin(angle) * radius; + + if (i === 0) { + shape.moveTo(x, y); + } else { + shape.lineTo(x, y); + } + } + + const extrudeSettings = { + depth: 0.15, + bevelEnabled: true, + bevelThickness: 0.02, + bevelSize: 0.02, + bevelSegments: 2, + }; + + return new THREE.ExtrudeGeometry(shape, extrudeSettings); +} + +/** + * Create a canvas texture with the type letter + */ +function createLetterTexture(letter: string, color: string): THREE.CanvasTexture { + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 128; + const ctx = canvas.getContext('2d')!; + + // Transparent background + ctx.clearRect(0, 0, 128, 128); + + // Draw letter + ctx.fillStyle = color; + ctx.font = 'bold 80px system-ui, -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(letter, 64, 68); + + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + return texture; +} + +/** + * CustodianTypeIndicator Component + * + * Renders a 3D polygon with the custodian type letter(s) + */ +export const CustodianTypeIndicator: React.FC<CustodianTypeIndicatorProps> = ({ + types, + size = 32, + animate = false, + showTooltip = true, + className = '', +}) => { + const { language } = useLanguage(); + const containerRef = useRef<HTMLDivElement>(null); + const rendererRef = useRef<THREE.WebGLRenderer | null>(null); + const sceneRef = useRef<THREE.Scene | null>(null); + const cameraRef = useRef<THREE.OrthographicCamera | null>(null); + const meshRef = useRef<THREE.Mesh | null>(null); + const animationFrameRef = useRef<number | null>(null); + + // Get type configurations + const typeConfigs = useMemo(() => { + return types + .map(code => getCustodianTypeByCode(code)) + .filter((config): config is NonNullable<typeof config> => config !== undefined); + }, [types]); + + // Primary type for color (first one if multiple) + const primaryConfig = typeConfigs[0]; + + // Tooltip text + const tooltipText = useMemo(() => { + if (typeConfigs.length === 0) return ''; + return typeConfigs + .map(config => config.label[language]) + .join(', '); + }, [typeConfigs, language]); + + // Letters to display + const displayLetters = useMemo(() => { + if (types.length === 0) return '?'; + if (types.length === 1) return types[0]; + if (types.length <= 3) return types.join(''); + return types.slice(0, 2).join('') + '+'; + }, [types]); + + useEffect(() => { + console.log('[CustodianTypeIndicator] useEffect triggered'); + console.log('[CustodianTypeIndicator] containerRef.current:', containerRef.current); + console.log('[CustodianTypeIndicator] primaryConfig:', primaryConfig); + console.log('[CustodianTypeIndicator] types:', types); + console.log('[CustodianTypeIndicator] displayLetters:', displayLetters); + + if (!containerRef.current) { + console.warn('[CustodianTypeIndicator] No container ref - cannot render 3D polygon'); + return; + } + + if (!primaryConfig) { + console.warn('[CustodianTypeIndicator] No primaryConfig - cannot determine color'); + return; + } + + const container = containerRef.current; + console.log('[CustodianTypeIndicator] Container dimensions:', container.clientWidth, 'x', container.clientHeight); + + // Initialize scene + const scene = new THREE.Scene(); + sceneRef.current = scene; + console.log('[CustodianTypeIndicator] Scene created'); + + // Orthographic camera for flat 2D-like appearance + const aspect = 1; + const frustumSize = 2.5; + const camera = new THREE.OrthographicCamera( + -frustumSize * aspect / 2, + frustumSize * aspect / 2, + frustumSize / 2, + -frustumSize / 2, + 0.1, + 100 + ); + camera.position.z = 5; + cameraRef.current = camera; + console.log('[CustodianTypeIndicator] Camera created'); + + // Renderer with transparency + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + }); + renderer.setSize(size, size); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setClearColor(0x000000, 0); + container.appendChild(renderer.domElement); + rendererRef.current = renderer; + console.log('[CustodianTypeIndicator] Renderer created and appended to container'); + } catch (err) { + console.error('[CustodianTypeIndicator] Failed to create WebGL renderer:', err); + return; + } + + // Create 19-sided polygon geometry (one side per GLAMORCUBESFIXPHDNT letter) + console.log('[CustodianTypeIndicator] Creating 19-sided polygon (enneadecagon) for GLAMORCUBESFIXPHDNT'); + const geometry = createPolygonGeometry(POLYGON_SIDES, 1); + console.log('[CustodianTypeIndicator] Geometry created:', geometry); + + // Material with custodian type color + const primaryColor = new THREE.Color(primaryConfig.color); + console.log('[CustodianTypeIndicator] Using color:', primaryConfig.color); + const material = new THREE.MeshStandardMaterial({ + color: primaryColor, + metalness: 0.3, + roughness: 0.4, + side: THREE.DoubleSide, + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.rotation.x = 0.15; // Slight tilt for 3D effect + scene.add(mesh); + meshRef.current = mesh; + console.log('[CustodianTypeIndicator] Mesh added to scene'); + + // Create text sprite for the letter + const letterTexture = createLetterTexture(displayLetters, '#ffffff'); + const spriteMaterial = new THREE.SpriteMaterial({ + map: letterTexture, + transparent: true, + }); + const sprite = new THREE.Sprite(spriteMaterial); + sprite.scale.set(1.4, 1.4, 1); + sprite.position.z = 0.2; + scene.add(sprite); + console.log('[CustodianTypeIndicator] Letter sprite added:', displayLetters); + + // Lighting + const ambientLight = new THREE.AmbientLight(0xffffff, 0.8); + scene.add(ambientLight); + + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6); + directionalLight.position.set(2, 2, 5); + scene.add(directionalLight); + console.log('[CustodianTypeIndicator] Lighting added'); + + // Animation loop + let rotationAngle = 0; + const animateScene = () => { + animationFrameRef.current = requestAnimationFrame(animateScene); + + if (animate && mesh) { + rotationAngle += 0.01; + mesh.rotation.y = Math.sin(rotationAngle) * 0.3; + } + + renderer.render(scene, camera); + }; + animateScene(); + console.log('[CustodianTypeIndicator] Animation loop started'); + + // Initial render + renderer.render(scene, camera); + console.log('[CustodianTypeIndicator] Initial render complete'); + + // Cleanup + return () => { + console.log('[CustodianTypeIndicator] Cleanup triggered'); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + if (renderer) { + container.removeChild(renderer.domElement); + renderer.dispose(); + } + geometry.dispose(); + material.dispose(); + letterTexture.dispose(); + spriteMaterial.dispose(); + }; + }, [primaryConfig, displayLetters, size, animate, types]); + + if (!primaryConfig) { + console.warn('[CustodianTypeIndicator] Rendering null - no primaryConfig for types:', types); + return null; + } + + console.log('[CustodianTypeIndicator] Rendering container for types:', types, 'with size:', size); + + return ( + <div + ref={containerRef} + className={`custodian-type-indicator ${className}`} + title={showTooltip ? tooltipText : undefined} + style={{ + width: size, + height: size, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: showTooltip ? 'help' : 'default', + }} + /> + ); +}; + +/** + * Simplified 2D Badge version (CSS-based, no Three.js) + * For use in lists or where 3D is overkill + */ +export interface CustodianTypeBadgeProps { + /** Array of custodian type codes */ + types: CustodianTypeCode[]; + /** Size variant */ + size?: 'small' | 'medium' | 'large'; + /** Show label text */ + showLabel?: boolean; + /** Custom CSS class */ + className?: string; +} + +export const CustodianTypeBadge: React.FC<CustodianTypeBadgeProps> = ({ + types, + size = 'medium', + showLabel = false, + className = '', +}) => { + const { language } = useLanguage(); + + const typeConfigs = useMemo(() => { + return types + .map(code => getCustodianTypeByCode(code)) + .filter((config): config is NonNullable<typeof config> => config !== undefined); + }, [types]); + + if (typeConfigs.length === 0) { + return null; + } + + const primaryConfig = typeConfigs[0]; + + const sizeClasses = { + small: { fontSize: '10px', padding: '2px 4px', minWidth: '16px' }, + medium: { fontSize: '12px', padding: '3px 6px', minWidth: '20px' }, + large: { fontSize: '14px', padding: '4px 8px', minWidth: '24px' }, + }; + + const displayLetters = types.length <= 3 + ? types.join('') + : types.slice(0, 2).join('') + '+'; + + return ( + <span + className={`custodian-type-badge ${className}`} + title={typeConfigs.map(c => c.label[language]).join(', ')} + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + backgroundColor: primaryConfig.bgColor, + color: primaryConfig.textColor, + border: `1px solid ${primaryConfig.borderColor}`, + borderRadius: '4px', + fontWeight: 600, + fontFamily: 'system-ui, -apple-system, sans-serif', + whiteSpace: 'nowrap', + ...sizeClasses[size], + }} + > + <span + style={{ + backgroundColor: primaryConfig.color, + color: '#ffffff', + borderRadius: '2px', + padding: '1px 3px', + fontSize: 'inherit', + lineHeight: 1, + }} + > + {displayLetters} + </span> + {showLabel && ( + <span style={{ color: primaryConfig.textColor }}> + {primaryConfig.label[language]} + </span> + )} + </span> + ); +}; + +/** + * Multi-type indicator showing all types in a row + */ +export interface CustodianTypeRowProps { + /** Array of custodian type codes */ + types: CustodianTypeCode[]; + /** Maximum types to show before collapsing */ + maxVisible?: number; + /** Size variant */ + size?: 'small' | 'medium' | 'large'; + /** Custom CSS class */ + className?: string; +} + +export const CustodianTypeRow: React.FC<CustodianTypeRowProps> = ({ + types, + maxVisible = 5, + size = 'small', + className = '', +}) => { + const { language } = useLanguage(); + + const visibleTypes = types.slice(0, maxVisible); + const hiddenCount = types.length - maxVisible; + + const sizeStyles = { + small: { width: '16px', height: '16px', fontSize: '10px' }, + medium: { width: '20px', height: '20px', fontSize: '12px' }, + large: { width: '24px', height: '24px', fontSize: '14px' }, + }; + + return ( + <div + className={`custodian-type-row ${className}`} + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: '2px', + }} + > + {visibleTypes.map(code => { + const config = getCustodianTypeByCode(code); + if (!config) return null; + + return ( + <span + key={code} + title={config.label[language]} + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: config.color, + color: '#ffffff', + borderRadius: '3px', + fontWeight: 700, + fontFamily: 'system-ui, -apple-system, sans-serif', + ...sizeStyles[size], + }} + > + {code} + </span> + ); + })} + {hiddenCount > 0 && ( + <span + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#94a3b8', + color: '#ffffff', + borderRadius: '3px', + fontWeight: 600, + fontSize: sizeStyles[size].fontSize, + padding: '0 3px', + height: sizeStyles[size].height, + }} + title={`+${hiddenCount} more`} + > + +{hiddenCount} + </span> + )} + </div> + ); +}; + +export default CustodianTypeIndicator; diff --git a/frontend/src/contexts/LanguageContext.tsx b/frontend/src/contexts/LanguageContext.tsx index 4a4ce2955e..dd1d090036 100644 --- a/frontend/src/contexts/LanguageContext.tsx +++ b/frontend/src/contexts/LanguageContext.tsx @@ -75,6 +75,7 @@ export const translations = { map: { nl: 'Kaart', en: 'Map' }, stats: { nl: 'Statistieken', en: 'Stats' }, overview: { nl: 'Overzicht', en: 'Overview' }, + gesprek: { nl: 'Gesprek', en: 'Chat' }, settings: { nl: 'Instellingen', en: 'Settings' }, signOut: { nl: 'Uitloggen', en: 'Sign Out' }, }, diff --git a/frontend/src/hooks/useMultiDatabaseRAG.ts b/frontend/src/hooks/useMultiDatabaseRAG.ts new file mode 100644 index 0000000000..029486374b --- /dev/null +++ b/frontend/src/hooks/useMultiDatabaseRAG.ts @@ -0,0 +1,657 @@ +/** + * useMultiDatabaseRAG.ts - Multi-Database RAG (Retrieval-Augmented Generation) Hook + * + * Orchestrates queries across multiple databases for conversational AI: + * - Qdrant: Vector similarity search for semantic retrieval + * - Oxigraph: SPARQL queries for structured RDF data + * - TypeDB: TypeQL queries for knowledge graph traversal + * + * Based on DSPy RAG patterns for heritage institution conversations. + * Self-hosted infrastructure - no external API keys required. + * + * @see https://dspy.ai/ + */ + +import { useState, useCallback } from 'react'; +import type { QdrantSearchResult } from './useQdrant'; + +// Configuration - all services use Caddy proxy paths +const API_BASE = ''; // Relative URLs via Caddy proxy +const QDRANT_URL = '/qdrant'; +const SPARQL_URL = '/sparql'; +const TYPEDB_URL = '/api/typedb'; +const DSPY_URL = '/api/dspy'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface RAGContext { + qdrantResults: QdrantSearchResult[]; + sparqlResults: Record<string, unknown>[]; + typedbResults: Record<string, unknown>[]; + totalRetrieved: number; +} + +export interface RAGResponse { + answer: string; + sparqlQuery?: string; + typeqlQuery?: string; + context: RAGContext; + visualizationType?: VisualizationType; + visualizationData?: VisualizationData; + sources: RAGSource[]; + confidence: number; +} + +export interface RAGSource { + database: 'qdrant' | 'oxigraph' | 'typedb'; + id: string; + name?: string; + score?: number; + snippet?: string; +} + +export type VisualizationType = + | 'none' + | 'map' // Geographic visualization + | 'timeline' // Temporal visualization + | 'network' // Graph/relationship visualization + | 'chart' // Bar/line charts + | 'table' // Tabular data + | 'card' // Institution cards + | 'gallery'; // Image gallery + +export interface VisualizationData { + type: VisualizationType; + institutions?: InstitutionData[]; + coordinates?: GeoCoordinate[]; + timeline?: TimelineEvent[]; + graphData?: GraphVisualizationData; + chartData?: ChartData; +} + +export interface InstitutionData { + id: string; + name: string; + type?: string; + city?: string; + province?: string; + country?: string; + latitude?: number; + longitude?: number; + description?: string; + website?: string; + isil?: string; + wikidata?: string; + rating?: number; + reviews?: number; + photoCount?: number; +} + +export interface GeoCoordinate { + lat: number; + lng: number; + label: string; + type?: string; + data?: InstitutionData; +} + +export interface TimelineEvent { + date: string; + label: string; + description?: string; + type?: string; +} + +export interface GraphVisualizationData { + nodes: Array<{ + id: string; + label: string; + type: string; + attributes?: Record<string, unknown>; + }>; + edges: Array<{ + id: string; + source: string; + target: string; + label: string; + type?: string; + }>; +} + +export interface ChartData { + labels: string[]; + datasets: Array<{ + label: string; + data: number[]; + backgroundColor?: string | string[]; + borderColor?: string; + }>; +} + +export interface ConversationMessage { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: Date; + response?: RAGResponse; + isLoading?: boolean; + error?: string; +} + +export interface UseMultiDatabaseRAGReturn { + // State + isLoading: boolean; + error: Error | null; + lastContext: RAGContext | null; + + // Core RAG function + queryRAG: ( + question: string, + options?: RAGOptions + ) => Promise<RAGResponse>; + + // Individual database queries (for debugging/advanced use) + searchQdrant: (query: string, limit?: number) => Promise<QdrantSearchResult[]>; + querySparql: (sparql: string) => Promise<Record<string, unknown>[]>; + queryTypeDB: (typeql: string) => Promise<Record<string, unknown>[]>; + + // Utility functions + clearContext: () => void; + detectVisualizationType: (question: string, results: RAGContext) => VisualizationType; +} + +export interface RAGOptions { + model?: string; + language?: 'nl' | 'en'; + maxQdrantResults?: number; + maxSparqlResults?: number; + maxTypeDBResults?: number; + includeSparql?: boolean; + includeTypeDB?: boolean; + conversationHistory?: ConversationMessage[]; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Generate text embedding using local embedding service or fallback + * In production, this would use a local embedding model (e.g., sentence-transformers) + * For now, we'll use keyword-based Qdrant filtering as a fallback + */ +async function generateEmbedding(text: string): Promise<number[] | null> { + try { + // Try local embedding service first + const response = await fetch(`${API_BASE}/api/embed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + + if (response.ok) { + const data = await response.json(); + return data.embedding; + } + } catch { + // Fallback: return null to use keyword search + } + return null; +} + +/** + * Search Qdrant using vector similarity or keyword filter + */ +async function qdrantSearch( + query: string, + limit: number = 10 +): Promise<QdrantSearchResult[]> { + const collectionName = 'heritage_custodians'; + + // Try to get embedding for semantic search + const embedding = await generateEmbedding(query); + + if (embedding) { + // Vector similarity search + const response = await fetch(`${QDRANT_URL}/collections/${collectionName}/points/search`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + vector: embedding, + limit, + with_payload: true, + }), + }); + + if (response.ok) { + const data = await response.json(); + return data.result || []; + } + } + + // Fallback: Scroll through points with keyword filter + // Extract keywords from query for filtering + const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2); + + const response = await fetch(`${QDRANT_URL}/collections/${collectionName}/points/scroll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + limit: limit * 2, // Get more to filter + with_payload: true, + with_vector: false, + }), + }); + + if (!response.ok) { + throw new Error(`Qdrant scroll failed: ${response.status}`); + } + + const data = await response.json(); + const points = data.result?.points || []; + + // Simple keyword matching in payload + const scored = points.map((p: { id: string | number; payload: Record<string, unknown> }) => { + const payload = p.payload || {}; + const text = JSON.stringify(payload).toLowerCase(); + const matches = keywords.filter(k => text.includes(k)).length; + return { + id: p.id, + score: matches / Math.max(keywords.length, 1), + payload, + }; + }); + + // Sort by score and return top results + return scored + .filter((p: { score: number }) => p.score > 0) + .sort((a: { score: number }, b: { score: number }) => b.score - a.score) + .slice(0, limit); +} + +/** + * Execute SPARQL query against Oxigraph + */ +async function sparqlQuery(query: string): Promise<Record<string, unknown>[]> { + const response = await fetch(`${SPARQL_URL}/query`, { + method: 'POST', + headers: { + 'Content-Type': 'application/sparql-query', + 'Accept': 'application/sparql-results+json', + }, + body: query, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`SPARQL query failed: ${response.status} - ${error}`); + } + + const data = await response.json(); + return data.results?.bindings || []; +} + +/** + * Execute TypeQL query against TypeDB + */ +async function typedbQuery(query: string): Promise<Record<string, unknown>[]> { + const response = await fetch(`${TYPEDB_URL}/query`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, queryType: 'read' }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`TypeDB query failed: ${response.status} - ${error}`); + } + + const data = await response.json(); + return data.results || []; +} + +/** + * Call DSPy backend to generate queries and response + */ +async function callDSPy( + question: string, + context: RAGContext, + options: RAGOptions +): Promise<{ + answer: string; + sparqlQuery?: string; + typeqlQuery?: string; + visualizationType?: VisualizationType; + confidence: number; +}> { + const response = await fetch(`${DSPY_URL}/rag-query`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + question, + context: { + qdrant_results: context.qdrantResults.slice(0, 5), + sparql_results: context.sparqlResults.slice(0, 10), + typedb_results: context.typedbResults.slice(0, 10), + }, + language: options.language || 'nl', + model: options.model || 'claude-sonnet-4-5-20250929', + conversation_history: options.conversationHistory?.slice(-4).map(m => ({ + role: m.role, + content: m.content, + })), + }), + }); + + if (!response.ok) { + // Fallback response if DSPy service unavailable + return { + answer: generateFallbackAnswer(question, context, options.language || 'nl'), + confidence: 0.5, + }; + } + + return response.json(); +} + +/** + * Generate a fallback answer when DSPy service is unavailable + */ +function generateFallbackAnswer( + _question: string, + context: RAGContext, + language: 'nl' | 'en' +): string { + const count = context.totalRetrieved; + + if (count === 0) { + return language === 'nl' + ? 'Geen resultaten gevonden voor uw vraag.' + : 'No results found for your question.'; + } + + const institutions = context.qdrantResults.slice(0, 5).map(r => { + const name = r.payload?.name || r.payload?.custodian_name || 'Unknown'; + return name; + }); + + if (language === 'nl') { + return `Ik heb ${count} resultaten gevonden. Enkele relevante instellingen: ${institutions.join(', ')}.`; + } + return `I found ${count} results. Some relevant institutions: ${institutions.join(', ')}.`; +} + +/** + * Detect appropriate visualization type based on question and results + */ +function detectVisualizationType( + question: string, + context: RAGContext +): VisualizationType { + const q = question.toLowerCase(); + + // Map visualization keywords + if (q.includes('kaart') || q.includes('map') || q.includes('waar') || + q.includes('where') || q.includes('locatie') || q.includes('location') || + q.includes('provincie') || q.includes('province') || q.includes('stad') || + q.includes('city') || q.includes('geografisch') || q.includes('geographic')) { + return 'map'; + } + + // Timeline keywords + if (q.includes('wanneer') || q.includes('when') || q.includes('geschiedenis') || + q.includes('history') || q.includes('tijdlijn') || q.includes('timeline') || + q.includes('opgericht') || q.includes('founded') || q.includes('jaar') || + q.includes('year')) { + return 'timeline'; + } + + // Network/graph keywords + if (q.includes('relatie') || q.includes('relationship') || q.includes('verbinding') || + q.includes('connection') || q.includes('netwerk') || q.includes('network') || + q.includes('samenwer') || q.includes('collaborat')) { + return 'network'; + } + + // Chart keywords + if (q.includes('hoeveel') || q.includes('how many') || q.includes('aantal') || + q.includes('count') || q.includes('statistiek') || q.includes('statistic') || + q.includes('verdeling') || q.includes('distribution') || q.includes('vergelijk') || + q.includes('compare')) { + return 'chart'; + } + + // If we have location data, show map + const hasCoordinates = context.qdrantResults.some(r => + r.payload?.latitude || r.payload?.coordinates + ); + if (hasCoordinates && context.totalRetrieved > 0) { + return 'map'; + } + + // Default to cards for institution results + if (context.qdrantResults.length > 0) { + return 'card'; + } + + return 'table'; +} + +/** + * Extract visualization data from RAG context + */ +function extractVisualizationData( + type: VisualizationType, + context: RAGContext +): VisualizationData { + const data: VisualizationData = { type }; + + // Extract institution data from Qdrant results + data.institutions = context.qdrantResults.map(r => { + const p = (r.payload || {}) as Record<string, unknown>; + const location = (p.location || {}) as Record<string, unknown>; + const coordinates = (p.coordinates || {}) as Record<string, unknown>; + return { + id: String(r.id), + name: String(p.name || p.custodian_name || p.institution_name || 'Unknown'), + type: String(p.type || p.institution_type || ''), + city: String(p.city || location.city || ''), + province: String(p.province || p.region || ''), + country: String(p.country || 'NL'), + latitude: Number(p.latitude || coordinates.lat || location.latitude), + longitude: Number(p.longitude || coordinates.lng || location.longitude), + description: String(p.description || ''), + website: String(p.website || p.url || ''), + isil: String(p.isil || p.isil_code || ''), + wikidata: String(p.wikidata || p.wikidata_id || ''), + rating: Number(p.rating || p.google_rating || 0), + reviews: Number(p.reviews || p.review_count || 0), + photoCount: Number(p.photoCount || p.photo_count || 0), + }; + }); + + // Extract coordinates for map + if (type === 'map') { + data.coordinates = data.institutions + .filter(i => i.latitude && i.longitude && !isNaN(i.latitude) && !isNaN(i.longitude)) + .map(i => ({ + lat: i.latitude!, + lng: i.longitude!, + label: i.name, + type: i.type, + data: i, + })); + } + + return data; +} + +// ============================================================================ +// Hook Implementation +// ============================================================================ + +export function useMultiDatabaseRAG(): UseMultiDatabaseRAGReturn { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<Error | null>(null); + const [lastContext, setLastContext] = useState<RAGContext | null>(null); + + /** + * Main RAG query function - orchestrates multi-database retrieval + */ + const queryRAG = useCallback(async ( + question: string, + options: RAGOptions = {} + ): Promise<RAGResponse> => { + setIsLoading(true); + setError(null); + + const { + maxQdrantResults = 20, + maxSparqlResults = 50, + maxTypeDBResults = 50, + includeSparql = true, + includeTypeDB = false, // Disabled by default (may not be running) + } = options; + + try { + // Parallel retrieval from all databases + const retrievalPromises: Promise<unknown>[] = [ + qdrantSearch(question, maxQdrantResults), + ]; + + // Add SPARQL if enabled (construct a basic query from keywords) + if (includeSparql) { + const keywords = question.split(/\s+/).filter(w => w.length > 2).slice(0, 3); + const sparqlSearchQuery = ` + PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> + PREFIX schema: <http://schema.org/> + PREFIX skos: <http://www.w3.org/2004/02/skos/core#> + + SELECT ?s ?label ?type WHERE { + ?s rdfs:label|schema:name|skos:prefLabel ?label . + OPTIONAL { ?s a ?type } + FILTER(CONTAINS(LCASE(STR(?label)), "${keywords[0]?.toLowerCase() || ''}")) + } + LIMIT ${maxSparqlResults} + `; + retrievalPromises.push( + sparqlQuery(sparqlSearchQuery).catch(() => []) + ); + } + + // Add TypeDB if enabled + if (includeTypeDB) { + const typeqlSearchQuery = `match $x isa heritage_custodian, has name $n; get $x, $n; limit ${maxTypeDBResults};`; + retrievalPromises.push( + typedbQuery(typeqlSearchQuery).catch(() => []) + ); + } + + // Wait for all retrievals + const results = await Promise.all(retrievalPromises); + + const qdrantResults = results[0] as QdrantSearchResult[]; + const sparqlResults = (includeSparql ? results[1] : []) as Record<string, unknown>[]; + const typedbResults = (includeTypeDB ? results[2] || results[1] : []) as Record<string, unknown>[]; + + const context: RAGContext = { + qdrantResults, + sparqlResults, + typedbResults, + totalRetrieved: qdrantResults.length + sparqlResults.length + typedbResults.length, + }; + + setLastContext(context); + + // Call DSPy to generate response + const dspyResponse = await callDSPy(question, context, options); + + // Detect visualization type + const vizType = dspyResponse.visualizationType || detectVisualizationType(question, context); + + // Extract visualization data + const vizData = extractVisualizationData(vizType, context); + + // Build sources list + const sources: RAGSource[] = [ + ...qdrantResults.slice(0, 5).map(r => ({ + database: 'qdrant' as const, + id: String(r.id), + name: String(r.payload?.name || r.payload?.custodian_name || ''), + score: r.score, + snippet: String(r.payload?.description || '').slice(0, 200), + })), + ]; + + return { + answer: dspyResponse.answer, + sparqlQuery: dspyResponse.sparqlQuery, + typeqlQuery: dspyResponse.typeqlQuery, + context, + visualizationType: vizType, + visualizationData: vizData, + sources, + confidence: dspyResponse.confidence, + }; + + } catch (err) { + const error = err instanceof Error ? err : new Error('RAG query failed'); + setError(error); + throw error; + } finally { + setIsLoading(false); + } + }, []); + + /** + * Direct Qdrant search (for debugging/advanced use) + */ + const searchQdrant = useCallback(async ( + query: string, + limit: number = 10 + ): Promise<QdrantSearchResult[]> => { + return qdrantSearch(query, limit); + }, []); + + /** + * Direct SPARQL query (for debugging/advanced use) + */ + const querySparql = useCallback(async ( + sparql: string + ): Promise<Record<string, unknown>[]> => { + return sparqlQuery(sparql); + }, []); + + /** + * Direct TypeDB query (for debugging/advanced use) + */ + const queryTypeDB = useCallback(async ( + typeql: string + ): Promise<Record<string, unknown>[]> => { + return typedbQuery(typeql); + }, []); + + /** + * Clear cached context + */ + const clearContext = useCallback(() => { + setLastContext(null); + setError(null); + }, []); + + return { + isLoading, + error, + lastContext, + queryRAG, + searchQdrant, + querySparql, + queryTypeDB, + clearContext, + detectVisualizationType, + }; +} + +export default useMultiDatabaseRAG; diff --git a/frontend/src/hooks/useWerkgebiedMapLibre.ts b/frontend/src/hooks/useWerkgebiedMapLibre.ts index 4cc60f0211..a1c43f8c2b 100644 --- a/frontend/src/hooks/useWerkgebiedMapLibre.ts +++ b/frontend/src/hooks/useWerkgebiedMapLibre.ts @@ -18,6 +18,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, LngLatLike } from 'maplibre-gl'; import type { Archive, WerkgebiedMapping, @@ -375,7 +376,7 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo const hideWerkgebied = useCallback(() => { if (!map) return; - const source = map.getSource(WERKGEBIED_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; + const source = map.getSource(WERKGEBIED_SOURCE_ID) as GeoJSONSource | undefined; if (source) { source.setData({ type: 'FeatureCollection', @@ -486,7 +487,7 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo } // Update source data - const source = map.getSource(WERKGEBIED_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; + const source = map.getSource(WERKGEBIED_SOURCE_ID) as GeoJSONSource | undefined; if (source) { source.setData({ type: 'FeatureCollection', @@ -509,12 +510,12 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo const geometry = feature.geometry; if (geometry.type === 'Polygon') { geometry.coordinates[0].forEach((coord: number[]) => { - bounds.extend([coord[0], coord[1]] as maplibregl.LngLatLike); + bounds.extend([coord[0], coord[1]] as LngLatLike); }); } else if (geometry.type === 'MultiPolygon') { geometry.coordinates.forEach((polygon: number[][][]) => { polygon[0].forEach((coord: number[]) => { - bounds.extend([coord[0], coord[1]] as maplibregl.LngLatLike); + bounds.extend([coord[0], coord[1]] as LngLatLike); }); }); } @@ -843,7 +844,7 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo } // Update source - const source = map.getSource(WERKGEBIED_SOURCE_ID) as maplibregl.GeoJSONSource | undefined; + const source = map.getSource(WERKGEBIED_SOURCE_ID) as GeoJSONSource | undefined; if (source) { source.setData({ type: 'FeatureCollection', @@ -864,12 +865,12 @@ export function useWerkgebiedMapLibre(map: maplibregl.Map | null): WerkgebiedHoo const geometry = feature.geometry; if (geometry.type === 'Polygon') { (geometry.coordinates[0] as number[][]).forEach((coord) => { - bounds.extend([coord[0], coord[1]] as maplibregl.LngLatLike); + bounds.extend([coord[0], coord[1]] as LngLatLike); }); } else if (geometry.type === 'MultiPolygon') { (geometry.coordinates as number[][][][]).forEach((polygon) => { (polygon[0] as number[][]).forEach((coord) => { - bounds.extend([coord[0], coord[1]] as maplibregl.LngLatLike); + bounds.extend([coord[0], coord[1]] as LngLatLike); }); }); } diff --git a/frontend/src/lib/custodian-types.ts b/frontend/src/lib/custodian-types.ts new file mode 100644 index 0000000000..aa26825805 --- /dev/null +++ b/frontend/src/lib/custodian-types.ts @@ -0,0 +1,386 @@ +/** + * GLAMORCUBESFIXPHDNT Taxonomy - Heritage Custodian Type Configuration + * + * This module provides centralized color, label, and metadata configuration + * for the 19-type GLAMORCUBESFIXPHDNT heritage custodian taxonomy. + * + * Mnemonic: Galleries, Libraries, Archives, Museums, Official institutions, + * Research centers, Corporations, Unknown, Botanical gardens/zoos, + * Education providers, Societies, Features, Intangible heritage groups, + * miXed, Personal collections, Holy sites, Digital platforms, NGOs, + * Taste/smell heritage + * + * @see AGENTS.md - Institution Type Taxonomy section + * @see schemas/20251121/linkml/modules/enums/CustodianTypeEnum.yaml + */ + +/** + * Single-letter codes for each custodian type (used in GHCID generation) + */ +export type CustodianTypeCode = + | 'G' | 'L' | 'A' | 'M' | 'O' | 'R' | 'C' | 'U' | 'B' | 'E' + | 'S' | 'F' | 'I' | 'X' | 'P' | 'H' | 'D' | 'N' | 'T'; + +/** + * Full custodian type names (matches LinkML enum values) + */ +export type CustodianType = + | 'GALLERY' | 'LIBRARY' | 'ARCHIVE' | 'MUSEUM' | 'OFFICIAL_INSTITUTION' + | 'RESEARCH_CENTER' | 'CORPORATION' | 'UNKNOWN' | 'BOTANICAL_ZOO' + | 'EDUCATION_PROVIDER' | 'COLLECTING_SOCIETY' | 'FEATURES' + | 'INTANGIBLE_HERITAGE_GROUP' | 'MIXED' | 'PERSONAL_COLLECTION' + | 'HOLY_SITES' | 'DIGITAL_PLATFORM' | 'NGO' | 'TASTE_SMELL'; + +/** + * Bilingual labels for each custodian type + */ +export interface BilingualLabel { + nl: string; + en: string; +} + +/** + * Complete configuration for a single custodian type + */ +export interface CustodianTypeConfig { + /** Single-letter code for GHCID */ + code: CustodianTypeCode; + /** Full enum name (LinkML) */ + name: CustodianType; + /** Primary color (hex) - used for map markers, badges, etc. */ + color: string; + /** Light background color (hex) - used for cards, highlights */ + bgColor: string; + /** Border/accent color (hex) - used for outlines */ + borderColor: string; + /** Text color for high contrast on bgColor */ + textColor: string; + /** Bilingual display labels */ + label: BilingualLabel; + /** Short description */ + description: BilingualLabel; + /** Icon name (Lucide React) */ + icon: string; +} + +/** + * Complete GLAMORCUBESFIXPHDNT taxonomy configuration + * + * Colors are designed to be: + * - Distinguishable from each other + * - Colorblind-friendly where possible + * - Consistent with the existing map page colors + * - Suitable for both light and dark modes (primary colors work on both) + */ +export const CUSTODIAN_TYPES: Record<CustodianTypeCode, CustodianTypeConfig> = { + G: { + code: 'G', + name: 'GALLERY', + color: '#00bcd4', // Cyan + bgColor: '#e0f7fa', + borderColor: '#0097a7', + textColor: '#006064', + label: { nl: 'Galerie', en: 'Gallery' }, + description: { nl: 'Kunstgalerij of tentoonstellingsruimte', en: 'Art gallery or exhibition space' }, + icon: 'Frame', + }, + L: { + code: 'L', + name: 'LIBRARY', + color: '#2ecc71', // Green + bgColor: '#e8f5e9', + borderColor: '#27ae60', + textColor: '#1b5e20', + label: { nl: 'Bibliotheek', en: 'Library' }, + description: { nl: 'Openbare, academische of gespecialiseerde bibliotheek', en: 'Public, academic, or specialized library' }, + icon: 'BookOpen', + }, + A: { + code: 'A', + name: 'ARCHIVE', + color: '#3498db', // Blue + bgColor: '#e3f2fd', + borderColor: '#2980b9', + textColor: '#0d47a1', + label: { nl: 'Archief', en: 'Archive' }, + description: { nl: 'Overheids-, bedrijfs- of persoonlijk archief', en: 'Government, corporate, or personal archive' }, + icon: 'Archive', + }, + M: { + code: 'M', + name: 'MUSEUM', + color: '#e74c3c', // Red + bgColor: '#ffebee', + borderColor: '#c0392b', + textColor: '#b71c1c', + label: { nl: 'Museum', en: 'Museum' }, + description: { nl: 'Kunst-, geschiedenis- of wetenschapsmuseum', en: 'Art, history, or science museum' }, + icon: 'Building2', + }, + O: { + code: 'O', + name: 'OFFICIAL_INSTITUTION', + color: '#f39c12', // Orange + bgColor: '#fff8e1', + borderColor: '#e67e22', + textColor: '#e65100', + label: { nl: 'Officieel', en: 'Official' }, + description: { nl: 'Overheidserfgoedinstantie of -platform', en: 'Government heritage agency or platform' }, + icon: 'Landmark', + }, + R: { + code: 'R', + name: 'RESEARCH_CENTER', + color: '#1abc9c', // Teal + bgColor: '#e0f2f1', + borderColor: '#16a085', + textColor: '#004d40', + label: { nl: 'Onderzoek', en: 'Research' }, + description: { nl: 'Onderzoeksinstituut of documentatiecentrum', en: 'Research institute or documentation center' }, + icon: 'Search', + }, + C: { + code: 'C', + name: 'CORPORATION', + color: '#795548', // Brown + bgColor: '#efebe9', + borderColor: '#5d4037', + textColor: '#3e2723', + label: { nl: 'Bedrijf', en: 'Corporation' }, + description: { nl: 'Bedrijfserfgoedcollectie', en: 'Corporate heritage collection' }, + icon: 'Building', + }, + U: { + code: 'U', + name: 'UNKNOWN', + color: '#9e9e9e', // Gray + bgColor: '#f5f5f5', + borderColor: '#757575', + textColor: '#424242', + label: { nl: 'Onbekend', en: 'Unknown' }, + description: { nl: 'Type kan niet worden bepaald', en: 'Type cannot be determined' }, + icon: 'HelpCircle', + }, + B: { + code: 'B', + name: 'BOTANICAL_ZOO', + color: '#4caf50', // Green (different shade) + bgColor: '#e8f5e9', + borderColor: '#388e3c', + textColor: '#1b5e20', + label: { nl: 'Botanisch', en: 'Botanical' }, + description: { nl: 'Botanische tuin of dierentuin', en: 'Botanical garden or zoo' }, + icon: 'Leaf', + }, + E: { + code: 'E', + name: 'EDUCATION_PROVIDER', + color: '#ff9800', // Amber + bgColor: '#fff3e0', + borderColor: '#f57c00', + textColor: '#e65100', + label: { nl: 'Onderwijs', en: 'Education' }, + description: { nl: 'Onderwijsinstelling met collecties', en: 'Educational institution with collections' }, + icon: 'GraduationCap', + }, + S: { + code: 'S', + name: 'COLLECTING_SOCIETY', + color: '#9b59b6', // Purple + bgColor: '#f3e5f5', + borderColor: '#8e24aa', + textColor: '#4a148c', + label: { nl: 'Vereniging', en: 'Society' }, + description: { nl: 'Vereniging die gespecialiseerde materialen verzamelt', en: 'Society collecting specialized materials' }, + icon: 'Users', + }, + F: { + code: 'F', + name: 'FEATURES', + color: '#95a5a6', // Gray-green + bgColor: '#eceff1', + borderColor: '#78909c', + textColor: '#37474f', + label: { nl: 'Monumenten', en: 'Features' }, + description: { nl: 'Fysieke landschapskenmerken met erfgoedwaarde', en: 'Physical landscape features with heritage significance' }, + icon: 'Map', + }, + I: { + code: 'I', + name: 'INTANGIBLE_HERITAGE_GROUP', + color: '#673ab7', // Deep purple + bgColor: '#ede7f6', + borderColor: '#5e35b1', + textColor: '#311b92', + label: { nl: 'Immaterieel', en: 'Intangible' }, + description: { nl: 'Organisatie die immaterieel erfgoed bewaart', en: 'Organization preserving intangible heritage' }, + icon: 'Music', + }, + X: { + code: 'X', + name: 'MIXED', + color: '#607d8b', // Blue-gray + bgColor: '#eceff1', + borderColor: '#546e7a', + textColor: '#263238', + label: { nl: 'Gemengd', en: 'Mixed' }, + description: { nl: 'Meerdere types (gecombineerde faciliteit)', en: 'Multiple types (combined facility)' }, + icon: 'Layers', + }, + P: { + code: 'P', + name: 'PERSONAL_COLLECTION', + color: '#8bc34a', // Light green + bgColor: '#f1f8e9', + borderColor: '#689f38', + textColor: '#33691e', + label: { nl: 'Persoonlijk', en: 'Personal' }, + description: { nl: 'Privé persoonlijke collectie', en: 'Private personal collection' }, + icon: 'User', + }, + H: { + code: 'H', + name: 'HOLY_SITES', + color: '#607d8b', // Blue-gray (same as Mixed - consider changing) + bgColor: '#fce4ec', + borderColor: '#c2185b', + textColor: '#880e4f', + label: { nl: 'Heilige plaatsen', en: 'Holy sites' }, + description: { nl: 'Religieuze erfgoedlocaties en -instellingen', en: 'Religious heritage sites and institutions' }, + icon: 'Church', + }, + D: { + code: 'D', + name: 'DIGITAL_PLATFORM', + color: '#34495e', // Dark gray-blue + bgColor: '#e8eaf6', + borderColor: '#3949ab', + textColor: '#1a237e', + label: { nl: 'Digitaal', en: 'Digital' }, + description: { nl: 'Digitale erfgoedplatforms en repositories', en: 'Digital heritage platforms and repositories' }, + icon: 'Monitor', + }, + N: { + code: 'N', + name: 'NGO', + color: '#e91e63', // Pink + bgColor: '#fce4ec', + borderColor: '#c2185b', + textColor: '#880e4f', + label: { nl: 'NGO', en: 'NGO' }, + description: { nl: 'Niet-gouvernementele erfgoedorganisatie', en: 'Non-governmental heritage organization' }, + icon: 'Heart', + }, + T: { + code: 'T', + name: 'TASTE_SMELL', + color: '#ff5722', // Deep orange + bgColor: '#fbe9e7', + borderColor: '#e64a19', + textColor: '#bf360c', + label: { nl: 'Smaak/geur', en: 'Taste/smell' }, + description: { nl: 'Culinair en olfactorisch erfgoedinstelling', en: 'Culinary and olfactory heritage institution' }, + icon: 'ChefHat', + }, +}; + +/** + * Get custodian type configuration by single-letter code + * Returns undefined if code is not valid + */ +export function getCustodianTypeByCode(code: CustodianTypeCode | string): CustodianTypeConfig | undefined { + if (!code || typeof code !== 'string') return undefined; + const upperCode = code.toUpperCase(); + if (upperCode in CUSTODIAN_TYPES) { + return CUSTODIAN_TYPES[upperCode as CustodianTypeCode]; + } + return undefined; +} + +/** + * Get custodian type configuration by single-letter code, with fallback to UNKNOWN + * Always returns a valid config (never undefined) + */ +export function getCustodianTypeByCodeSafe(code: CustodianTypeCode | string): CustodianTypeConfig { + return getCustodianTypeByCode(code) ?? CUSTODIAN_TYPES.U; +} + +/** + * Get custodian type configuration by full name + */ +export function getCustodianTypeByName(name: CustodianType): CustodianTypeConfig | undefined { + return Object.values(CUSTODIAN_TYPES).find(t => t.name === name); +} + +/** + * Get all custodian type codes as array (in GLAMORCUBESFIXPHDNT order) + */ +export const CUSTODIAN_TYPE_CODES: CustodianTypeCode[] = [ + 'G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', + 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T' +]; + +/** + * Color-only map for backwards compatibility with existing code + * (Same as TYPE_COLORS in NDEMapPageMapLibre.tsx) + */ +export const CUSTODIAN_TYPE_COLORS: Record<CustodianTypeCode, string> = Object.fromEntries( + CUSTODIAN_TYPE_CODES.map(code => [code, CUSTODIAN_TYPES[code].color]) +) as Record<CustodianTypeCode, string>; + +/** + * Label-only map for backwards compatibility + * (Same as TYPE_NAMES in NDEMapPageMapLibre.tsx) + */ +export const CUSTODIAN_TYPE_LABELS: Record<CustodianTypeCode, BilingualLabel> = Object.fromEntries( + CUSTODIAN_TYPE_CODES.map(code => [code, CUSTODIAN_TYPES[code].label]) +) as Record<CustodianTypeCode, BilingualLabel>; + +/** + * Full name to code mapping + */ +export const NAME_TO_CODE: Record<CustodianType, CustodianTypeCode> = Object.fromEntries( + Object.entries(CUSTODIAN_TYPES).map(([code, config]) => [config.name, code as CustodianTypeCode]) +) as Record<CustodianType, CustodianTypeCode>; + +/** + * Code to full name mapping + */ +export const CODE_TO_NAME: Record<CustodianTypeCode, CustodianType> = Object.fromEntries( + CUSTODIAN_TYPE_CODES.map(code => [code, CUSTODIAN_TYPES[code].name]) +) as Record<CustodianTypeCode, CustodianType>; + +/** + * Parse a custodian type string (code or full name) to a code + * Returns undefined if not recognized + */ +export function parseCustodianType(input: string): CustodianTypeCode | undefined { + // Check if it's already a single-letter code + if (input.length === 1 && CUSTODIAN_TYPE_CODES.includes(input.toUpperCase() as CustodianTypeCode)) { + return input.toUpperCase() as CustodianTypeCode; + } + + // Check if it's a full name + const upperInput = input.toUpperCase().replace(/[-\s]/g, '_'); + if (upperInput in NAME_TO_CODE) { + return NAME_TO_CODE[upperInput as CustodianType]; + } + + return undefined; +} + +/** + * Get display color for a custodian type (by code or name) + */ +export function getCustodianTypeColor(input: string): string { + const code = parseCustodianType(input); + return code ? CUSTODIAN_TYPES[code].color : CUSTODIAN_TYPES.U.color; +} + +/** + * Get display label for a custodian type in specified language + */ +export function getCustodianTypeLabel(input: string, lang: 'nl' | 'en' = 'en'): string { + const code = parseCustodianType(input); + return code ? CUSTODIAN_TYPES[code].label[lang] : CUSTODIAN_TYPES.U.label[lang]; +} diff --git a/frontend/src/lib/schema-custodian-mapping.ts b/frontend/src/lib/schema-custodian-mapping.ts new file mode 100644 index 0000000000..3f791a915a --- /dev/null +++ b/frontend/src/lib/schema-custodian-mapping.ts @@ -0,0 +1,237 @@ +/** + * schema-custodian-mapping.ts - Maps LinkML schema elements to CustodianTypes + * + * This module provides mappings between schema elements (classes, slots, enums) + * and the GLAMORCUBESFIXPHDNT custodian types they primarily relate to. + * + * Used by the CustodianTypeIndicator component to show which type(s) a schema + * element is most relevant to. + */ + +import type { CustodianTypeCode } from './custodian-types'; + +/** + * Mapping of schema class names to relevant custodian types + * + * Key: Class name (as it appears in LinkML schema) + * Value: Array of CustodianTypeCode(s) the class relates to + * + * Most classes relate to ALL types (universal), but some are type-specific. + */ +export const CLASS_TO_CUSTODIAN_TYPE: Record<string, CustodianTypeCode[]> = { + // Universal classes (apply to all custodian types) + 'CustodianObservation': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'CustodianName': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'CustodianReconstruction': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'Location': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'GHCID': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'Provenance': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + + // Place-related classes + 'FeaturePlace': ['F'], // Features (monuments, statues) + 'FeaturePlaceClass': ['F'], + + // Collection-related classes + 'Collection': ['G', 'L', 'A', 'M', 'B', 'H'], // Galleries, Libraries, Archives, Museums, Botanical, Holy sites + 'CollectionItem': ['G', 'L', 'A', 'M', 'B', 'H'], + + // Digital platform classes + 'DigitalPlatform': ['D'], // Digital platforms + 'DigitalPlatformClass': ['D'], + 'WebObservation': ['D'], + 'WebClaim': ['D'], + + // Archive-specific + 'ArchivalFonds': ['A'], // Archives + 'ArchivalSeries': ['A'], + 'ArchivalRecord': ['A'], + + // Library-specific + 'BibliographicRecord': ['L'], // Libraries + 'Catalog': ['L'], + + // Museum-specific + 'Exhibition': ['M', 'G'], // Museums, Galleries + 'MuseumObject': ['M'], + + // Research-related + 'ResearchProject': ['R'], // Research centers + 'Publication': ['R', 'L'], // Research centers, Libraries + + // Education-related + 'Course': ['E'], // Education providers + 'LearningResource': ['E', 'D'], // Education, Digital platforms + + // Religious heritage + 'ReligiousCollection': ['H'], // Holy sites + 'LiturgicalObject': ['H'], + + // Botanical/Zoo + 'LivingCollection': ['B'], // Botanical gardens/zoos + 'Specimen': ['B'], + + // Intangible heritage + 'IntangibleHeritage': ['I'], // Intangible heritage groups + 'Performance': ['I'], + 'Tradition': ['I'], + + // Organizational + 'StaffRole': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'E', 'S', 'H', 'N'], + 'OrganizationalChange': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + + // Personal collections + 'PersonalCollection': ['P'], // Personal collections + 'PrivateArchive': ['P'], + + // Corporate + 'CorporateCollection': ['C'], // Corporations + 'CorporateArchive': ['C'], + + // Society-related + 'SocietyMembership': ['S'], // Collecting societies + 'HeemkundigeKring': ['S'], + + // Taste/Smell heritage + 'CulinaryHeritage': ['T'], // Taste/smell heritage + 'Recipe': ['T'], + 'Formulation': ['T'], + + // NGO-specific + 'AdvocacyOrganization': ['N'], // NGOs + 'HeritageInitiative': ['N'], +}; + +/** + * Mapping of schema slot names to relevant custodian types + */ +export const SLOT_TO_CUSTODIAN_TYPE: Record<string, CustodianTypeCode[]> = { + // Universal slots + 'custodian_name': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'location': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'ghcid': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'provenance': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + + // Archive-specific slots + 'fonds': ['A'], + 'finding_aid': ['A'], + 'archival_hierarchy': ['A'], + + // Library-specific slots + 'call_number': ['L'], + 'bibliographic_record': ['L'], + 'catalog_entry': ['L'], + + // Museum-specific slots + 'accession_number': ['M'], + 'exhibition_history': ['M', 'G'], + 'conservation_status': ['M', 'G'], + + // Digital platform slots + 'platform_url': ['D'], + 'api_endpoint': ['D'], + 'metadata_format': ['D', 'L', 'A', 'M'], + + // Religious heritage slots + 'denomination': ['H'], + 'consecration_date': ['H'], + 'liturgical_calendar': ['H'], + + // Botanical/Zoo slots + 'species': ['B'], + 'habitat': ['B'], + 'conservation_program': ['B'], + + // Intangible heritage slots + 'tradition_type': ['I'], + 'transmission_method': ['I'], + 'practitioners': ['I'], + + // Taste/Smell slots + 'recipe_origin': ['T'], + 'ingredients': ['T'], + 'preparation_method': ['T'], +}; + +/** + * Mapping of enum names to relevant custodian types + */ +export const ENUM_TO_CUSTODIAN_TYPE: Record<string, CustodianTypeCode[]> = { + // Universal enums + 'CustodianTypeEnum': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'DataTierEnum': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'DataSourceEnum': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'CountryCodeEnum': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + 'LanguageCodeEnum': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T'], + + // Type-specific enums + 'ArchivalLevelEnum': ['A'], + 'BibliographicFormatEnum': ['L'], + 'ExhibitionTypeEnum': ['M', 'G'], + 'DigitalPlatformTypeEnum': ['D'], + 'ReligiousDenominationEnum': ['H'], + 'SpeciesClassificationEnum': ['B'], + 'IntangibleHeritageTypeEnum': ['I'], + 'CulinaryHeritageTypeEnum': ['T'], + 'StaffRoleTypeEnum': ['G', 'L', 'A', 'M', 'O', 'R', 'C', 'E', 'S', 'H', 'N'], +}; + +/** + * Default types for elements not explicitly mapped (universal) + */ +export const DEFAULT_CUSTODIAN_TYPES: CustodianTypeCode[] = [ + 'G', 'L', 'A', 'M', 'O', 'R', 'C', 'U', 'B', 'E', 'S', 'F', 'I', 'X', 'P', 'H', 'D', 'N', 'T' +]; + +/** + * Get custodian types for a schema class + */ +export function getCustodianTypesForClass(className: string): CustodianTypeCode[] { + return CLASS_TO_CUSTODIAN_TYPE[className] || DEFAULT_CUSTODIAN_TYPES; +} + +/** + * Get custodian types for a schema slot + */ +export function getCustodianTypesForSlot(slotName: string): CustodianTypeCode[] { + return SLOT_TO_CUSTODIAN_TYPE[slotName] || DEFAULT_CUSTODIAN_TYPES; +} + +/** + * Get custodian types for a schema enum + */ +export function getCustodianTypesForEnum(enumName: string): CustodianTypeCode[] { + return ENUM_TO_CUSTODIAN_TYPE[enumName] || DEFAULT_CUSTODIAN_TYPES; +} + +/** + * Check if a schema element is universal (applies to all types) + */ +export function isUniversalElement(types: CustodianTypeCode[]): boolean { + return types.length >= 19; // All 19 types +} + +/** + * Get the primary custodian type for a schema element + * Returns the first type, or 'U' (Unknown) if empty + */ +export function getPrimaryCustodianType(types: CustodianTypeCode[]): CustodianTypeCode { + if (types.length === 0) return 'U'; + // For universal elements, return the most common types first + if (isUniversalElement(types)) { + return 'M'; // Museum as default primary for universal + } + return types[0]; +} + +/** + * Get a compact representation of custodian types + * For universal: returns "ALL" + * For few types: returns joined string (e.g., "MAL") + * For many types: returns abbreviated (e.g., "MA+5") + */ +export function getCompactTypeRepresentation(types: CustodianTypeCode[]): string { + if (types.length === 0) return '?'; + if (isUniversalElement(types)) return 'ALL'; + if (types.length <= 4) return types.join(''); + return types.slice(0, 2).join('') + `+${types.length - 2}`; +} diff --git a/frontend/src/pages/Database.css b/frontend/src/pages/Database.css index df5594195a..50fee7bd6e 100644 --- a/frontend/src/pages/Database.css +++ b/frontend/src/pages/Database.css @@ -3662,3 +3662,730 @@ body.resizing-row * { color: #888; } } + +/* ============================================ + EMBEDDING PROJECTOR STYLES + ============================================ */ + +.embedding-projector { + display: flex; + flex-direction: column; + height: 100%; + background: #fafafa; + border-radius: 8px; + overflow: hidden; +} + +.projector-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + background: #fff; + border-bottom: 1px solid #e0e0e0; + flex-shrink: 0; +} + +.projector-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #333; +} + +.projector-stats { + display: flex; + gap: 1rem; + font-size: 0.85rem; + color: #666; +} + +.projector-stats span { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.projector-controls { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.75rem 1.25rem; + background: #fff; + border-bottom: 1px solid #e0e0e0; + flex-shrink: 0; + flex-wrap: wrap; +} + +.control-section { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.control-section label { + font-size: 0.8rem; + font-weight: 500; + color: #555; + white-space: nowrap; +} + +.control-group { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.control-group select, +.control-group input[type="number"] { + padding: 0.4rem 0.6rem; + font-size: 0.85rem; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + color: #333; + min-width: 80px; +} + +.control-group select:focus, +.control-group input[type="number"]:focus { + outline: none; + border-color: #FFC107; + box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.2); +} + +.control-group input[type="number"] { + width: 70px; +} + +.button-group { + display: flex; + gap: 0.25rem; +} + +.button-group button { + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + border: 1px solid #ddd; + background: #fff; + color: #555; + cursor: pointer; + transition: all 0.15s; +} + +.button-group button:first-child { + border-radius: 4px 0 0 4px; +} + +.button-group button:last-child { + border-radius: 0 4px 4px 0; +} + +.button-group button:not(:last-child) { + border-right: none; +} + +.button-group button:hover { + background: #f5f5f5; +} + +.button-group button.active { + background: #FFC107; + border-color: #FFC107; + color: #000; + font-weight: 500; +} + +.compute-btn { + padding: 0.5rem 1rem; + font-size: 0.85rem; + font-weight: 500; + background: #FFC107; + border: none; + border-radius: 4px; + color: #000; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.compute-btn:hover:not(:disabled) { + background: #ffca2c; + transform: translateY(-1px); +} + +.compute-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.projector-body { + display: flex; + flex: 1; + overflow: hidden; +} + +.projector-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} + +.projector-canvas { + flex: 1; + position: relative; + background: #fff; + border-right: 1px solid #e0e0e0; + overflow: hidden; +} + +.projector-canvas svg { + width: 100%; + height: 100%; +} + +.projector-canvas .three-container { + width: 100%; + height: 100%; + min-height: 500px; +} + +.projector-canvas .three-container canvas { + width: 100% !important; + height: 100% !important; +} + +.projector-canvas .point { + cursor: pointer; + transition: r 0.15s, opacity 0.15s; +} + +.projector-canvas .point:hover { + r: 6; +} + +.projector-canvas .point.selected { + stroke: #000; + stroke-width: 2; +} + +.projector-canvas .point.neighbor { + stroke: #FFC107; + stroke-width: 2; +} + +.projector-canvas .point.dimmed { + opacity: 0.15; +} + +.projector-sidebar { + width: 280px; + display: flex; + flex-direction: column; + background: #fff; + border-left: 1px solid #e0e0e0; + overflow: hidden; + flex-shrink: 0; +} + +.projector-search { + padding: 0.75rem; + border-bottom: 1px solid #e0e0e0; +} + +.projector-search input { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + border: 1px solid #ddd; + border-radius: 4px; + background: #f8f8f8; +} + +.projector-search input:focus { + outline: none; + border-color: #FFC107; + background: #fff; +} + +.projector-legend { + padding: 0.75rem; + border-bottom: 1px solid #e0e0e0; + max-height: 200px; + overflow-y: auto; +} + +.projector-legend h4 { + margin: 0 0 0.5rem 0; + font-size: 0.8rem; + font-weight: 600; + color: #555; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.legend-items { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: #444; + cursor: pointer; + padding: 0.25rem; + border-radius: 3px; + transition: background 0.15s; +} + +.legend-item:hover { + background: #f5f5f5; +} + +.legend-color { + width: 12px; + height: 12px; + border-radius: 2px; + flex-shrink: 0; +} + +.legend-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.legend-count { + font-size: 0.75rem; + color: #888; +} + +.projector-details { + flex: 1; + padding: 0.75rem; + overflow-y: auto; +} + +.projector-details h4 { + margin: 0 0 0.75rem 0; + font-size: 0.8rem; + font-weight: 600; + color: #555; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.detail-section { + margin-bottom: 1rem; +} + +.detail-section:last-child { + margin-bottom: 0; +} + +.detail-label { + font-size: 0.75rem; + color: #888; + margin-bottom: 0.25rem; +} + +.detail-value { + font-size: 0.85rem; + color: #333; + word-break: break-all; +} + +.detail-value.id { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.8rem; + background: #f5f5f5; + padding: 0.25rem 0.5rem; + border-radius: 3px; +} + +.nearest-neighbors { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e0e0e0; +} + +.nearest-neighbors h5 { + margin: 0 0 0.5rem 0; + font-size: 0.8rem; + font-weight: 600; + color: #555; +} + +.neighbor-list { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.neighbor-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.35rem 0.5rem; + background: #f8f8f8; + border-radius: 3px; + font-size: 0.8rem; + cursor: pointer; + transition: background 0.15s; +} + +.neighbor-item:hover { + background: #FFC107; +} + +.neighbor-id { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.75rem; + color: #555; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 140px; +} + +.neighbor-distance { + font-size: 0.75rem; + color: #888; +} + +.no-selection { + text-align: center; + padding: 2rem 1rem; + color: #888; + font-size: 0.85rem; +} + +.viz-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 2rem; + text-align: center; + color: #666; +} + +.viz-placeholder svg { + width: 64px; + height: 64px; + margin-bottom: 1rem; + opacity: 0.5; +} + +.viz-placeholder p { + margin: 0; + font-size: 0.9rem; +} + +.viz-placeholder p:first-of-type { + font-weight: 500; + color: #444; + margin-bottom: 0.5rem; +} + +.viz-collection-selector { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + margin-top: 1.5rem; +} + +.viz-collection-selector select { + padding: 0.5rem 1rem; + font-size: 0.9rem; + border: 1px solid #ddd; + border-radius: 4px; + background: #fff; + min-width: 200px; +} + +.viz-collection-selector button { + padding: 0.5rem 1.5rem; + font-size: 0.9rem; + font-weight: 500; + background: #FFC107; + border: none; + border-radius: 4px; + color: #000; + cursor: pointer; + transition: all 0.15s; +} + +.viz-collection-selector button:hover:not(:disabled) { + background: #ffca2c; +} + +.viz-collection-selector button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.variance-info { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: #666; + padding: 0.5rem 1rem; + background: #f8f8f8; + border-radius: 4px; + margin-left: auto; +} + +.variance-info strong { + color: #333; +} + +.computing-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10; +} + +.computing-spinner { + width: 40px; + height: 40px; + border: 3px solid #e0e0e0; + border-top-color: #FFC107; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.computing-overlay p { + font-size: 0.9rem; + color: #666; + margin: 0; +} + +.tooltip { + position: absolute; + pointer-events: none; + background: rgba(0, 0, 0, 0.85); + color: #fff; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.8rem; + max-width: 250px; + z-index: 100; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.tooltip-id { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.75rem; + opacity: 0.8; + margin-bottom: 0.25rem; +} + +.tooltip-payload { + font-size: 0.75rem; +} + +/* Dark mode for Embedding Projector */ +[data-theme="dark"] .embedding-projector { + background: #16161e; +} + +[data-theme="dark"] .projector-header { + background: #1a1a2e; + border-color: #333; +} + +[data-theme="dark"] .projector-header h3 { + color: #e0e0e0; +} + +[data-theme="dark"] .projector-stats { + color: #888; +} + +[data-theme="dark"] .projector-controls { + background: #1a1a2e; + border-color: #333; +} + +[data-theme="dark"] .control-section label { + color: #aaa; +} + +[data-theme="dark"] .control-group select, +[data-theme="dark"] .control-group input[type="number"] { + background: #252538; + border-color: #404050; + color: #e0e0e0; +} + +[data-theme="dark"] .button-group button { + background: #252538; + border-color: #404050; + color: #aaa; +} + +[data-theme="dark"] .button-group button:hover { + background: #3a3a4e; +} + +[data-theme="dark"] .button-group button.active { + background: #FFC107; + border-color: #FFC107; + color: #000; +} + +[data-theme="dark"] .projector-canvas { + background: #1a1a2e; + border-color: #333; +} + +[data-theme="dark"] .projector-canvas .three-container { + background: #1a1a2e; +} + +[data-theme="dark"] .projector-sidebar { + background: #1a1a2e; + border-color: #333; +} + +[data-theme="dark"] .projector-search input { + background: #252538; + border-color: #404050; + color: #e0e0e0; +} + +[data-theme="dark"] .projector-search input:focus { + background: #2a2a3e; + border-color: #FFC107; +} + +[data-theme="dark"] .projector-legend { + border-color: #333; +} + +[data-theme="dark"] .projector-legend h4 { + color: #aaa; +} + +[data-theme="dark"] .legend-item { + color: #ccc; +} + +[data-theme="dark"] .legend-item:hover { + background: #252538; +} + +[data-theme="dark"] .legend-count { + color: #666; +} + +[data-theme="dark"] .projector-details h4 { + color: #aaa; +} + +[data-theme="dark"] .detail-label { + color: #666; +} + +[data-theme="dark"] .detail-value { + color: #e0e0e0; +} + +[data-theme="dark"] .detail-value.id { + background: #252538; +} + +[data-theme="dark"] .nearest-neighbors { + border-color: #333; +} + +[data-theme="dark"] .nearest-neighbors h5 { + color: #aaa; +} + +[data-theme="dark"] .neighbor-item { + background: #252538; +} + +[data-theme="dark"] .neighbor-item:hover { + background: #FFC107; +} + +[data-theme="dark"] .neighbor-item:hover .neighbor-id, +[data-theme="dark"] .neighbor-item:hover .neighbor-distance { + color: #000; +} + +[data-theme="dark"] .neighbor-id { + color: #aaa; +} + +[data-theme="dark"] .neighbor-distance { + color: #666; +} + +[data-theme="dark"] .no-selection { + color: #666; +} + +[data-theme="dark"] .viz-placeholder { + color: #888; +} + +[data-theme="dark"] .viz-placeholder p:first-of-type { + color: #aaa; +} + +[data-theme="dark"] .viz-collection-selector select { + background: #252538; + border-color: #404050; + color: #e0e0e0; +} + +[data-theme="dark"] .variance-info { + background: #252538; + color: #888; +} + +[data-theme="dark"] .variance-info strong { + color: #e0e0e0; +} + +[data-theme="dark"] .computing-overlay { + background: rgba(22, 22, 30, 0.95); +} + +[data-theme="dark"] .computing-spinner { + border-color: #333; + border-top-color: #FFC107; +} + +[data-theme="dark"] .computing-overlay p { + color: #888; +} diff --git a/frontend/src/pages/GesprekPage.css b/frontend/src/pages/GesprekPage.css new file mode 100644 index 0000000000..bce4e31ed0 --- /dev/null +++ b/frontend/src/pages/GesprekPage.css @@ -0,0 +1,1370 @@ +/** + * GesprekPage.css - Conversational Heritage Institution Explorer Styles + * + * NDE House Style colors: + * - Primary Blue: #154273 + * - Light Blue: #e8f4fc + * - White: #ffffff + * - Text Gray: #333333 + * - Border Gray: #e5e5e5 + * + * © 2025 Netwerk Digitaal Erfgoed & TextPast. All rights reserved. + */ + +/* ============================================================================ + Page Layout + ============================================================================ */ + +.gesprek-page { + display: flex; + flex-direction: column; + height: calc(100vh - 64px); /* Account for navigation */ + background-color: var(--bg-secondary, #f8f9fa); + position: relative; +} + +.gesprek-layout { + display: grid; + grid-template-columns: 1fr 400px; + gap: 0; + height: 100%; + overflow: hidden; +} + +@media (max-width: 1024px) { + .gesprek-layout { + grid-template-columns: 1fr; + } + + .gesprek-viz-panel { + display: none; + } + + .gesprek-viz-panel--expanded { + display: block; + position: fixed; + inset: 0; + z-index: 100; + } +} + +/* ============================================================================ + Notification Toast + ============================================================================ */ + +.gesprek-notification { + position: fixed; + top: 80px; + left: 50%; + transform: translateX(-50%); + background: var(--primary-blue, #154273); + color: white; + padding: 12px 24px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 200; + animation: slideDown 0.3s ease; +} + +.gesprek-notification__close { + background: none; + border: none; + color: white; + cursor: pointer; + padding: 4px; + display: flex; + opacity: 0.7; + transition: opacity 0.2s; +} + +.gesprek-notification__close:hover { + opacity: 1; +} + +@keyframes slideDown { + from { + transform: translateX(-50%) translateY(-20px); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } +} + +/* ============================================================================ + Chat Panel + ============================================================================ */ + +.gesprek-chat { + display: flex; + flex-direction: column; + height: 100%; + background: white; + border-right: 1px solid var(--border-color, #e5e5e5); +} + +.gesprek-chat__header { + padding: 16px 24px; + border-bottom: 1px solid var(--border-color, #e5e5e5); + background: linear-gradient(135deg, var(--primary-blue, #154273) 0%, #1a5490 100%); + color: white; +} + +.gesprek-chat__title { + display: flex; + align-items: center; + gap: 12px; +} + +.gesprek-chat__title h1 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.gesprek-chat__title p { + font-size: 0.875rem; + margin: 4px 0 0 0; + opacity: 0.9; +} + +.gesprek-chat__icon { + color: #ffd700; +} + +/* ============================================================================ + Input Area + ============================================================================ */ + +.gesprek-chat__input-area { + padding: 16px 24px; + border-bottom: 1px solid var(--border-color, #e5e5e5); + background: var(--bg-secondary, #f8f9fa); +} + +.gesprek-chat__input-container { + display: flex; + gap: 8px; + background: white; + border: 2px solid var(--border-color, #e5e5e5); + border-radius: 12px; + padding: 8px 12px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.gesprek-chat__input-container:focus-within { + border-color: var(--primary-blue, #154273); + box-shadow: 0 0 0 3px rgba(21, 66, 115, 0.1); +} + +.gesprek-chat__input { + flex: 1; + border: none; + background: none; + font-size: 1rem; + line-height: 1.5; + resize: none; + min-height: 24px; + max-height: 150px; + font-family: inherit; +} + +.gesprek-chat__input:focus { + outline: none; +} + +.gesprek-chat__input::placeholder { + color: #999; +} + +.gesprek-chat__send-btn { + background: var(--primary-blue, #154273); + color: white; + border: none; + border-radius: 8px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s, transform 0.1s; + flex-shrink: 0; +} + +.gesprek-chat__send-btn:hover:not(:disabled) { + background: #1a5490; + transform: scale(1.05); +} + +.gesprek-chat__send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.gesprek-chat__send-icon--loading { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Toolbar */ +.gesprek-chat__toolbar { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; +} + +.gesprek-chat__model-selector { + position: relative; +} + +.gesprek-chat__model-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: white; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 6px; + font-size: 0.8125rem; + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s; +} + +.gesprek-chat__model-btn:hover { + background: var(--bg-secondary, #f8f9fa); + border-color: var(--primary-blue, #154273); +} + +.gesprek-chat__model-label { + color: #666; +} + +.gesprek-chat__model-name { + font-weight: 500; + color: var(--primary-blue, #154273); +} + +.gesprek-chat__model-chevron { + color: #999; + transition: transform 0.2s; +} + +.gesprek-chat__model-chevron--open { + transform: rotate(180deg); +} + +.gesprek-chat__model-dropdown { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + background: white; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 50; + min-width: 280px; + overflow: hidden; +} + +.gesprek-chat__model-option { + display: flex; + flex-direction: column; + padding: 10px 14px; + background: none; + border: none; + width: 100%; + text-align: left; + cursor: pointer; + transition: background-color 0.15s; +} + +.gesprek-chat__model-option:hover { + background: var(--bg-secondary, #f8f9fa); +} + +.gesprek-chat__model-option--selected { + background: var(--light-blue, #e8f4fc); +} + +.gesprek-chat__model-option-name { + font-weight: 500; + color: var(--text-primary, #333); +} + +.gesprek-chat__model-option-desc { + font-size: 0.75rem; + color: #666; + margin-top: 2px; +} + +/* Actions */ +.gesprek-chat__actions { + display: flex; + gap: 4px; + margin-left: auto; +} + +.gesprek-chat__action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: white; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 6px; + color: #666; + cursor: pointer; + transition: all 0.2s; +} + +.gesprek-chat__action-btn:hover:not(:disabled) { + background: var(--bg-secondary, #f8f9fa); + border-color: var(--primary-blue, #154273); + color: var(--primary-blue, #154273); +} + +.gesprek-chat__action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.gesprek-chat__action-btn--danger:hover:not(:disabled) { + border-color: #dc3545; + color: #dc3545; + background: #fff5f5; +} + +/* History dropdown */ +.gesprek-chat__history-selector { + position: relative; +} + +.gesprek-chat__history-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: white; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 50; + min-width: 300px; + max-height: 400px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.gesprek-chat__history-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + border-bottom: 1px solid var(--border-color, #e5e5e5); + font-weight: 500; +} + +.gesprek-chat__history-clear { + font-size: 0.75rem; + color: #dc3545; + background: none; + border: none; + cursor: pointer; +} + +.gesprek-chat__history-clear:hover { + text-decoration: underline; +} + +.gesprek-chat__history-empty { + padding: 20px; + text-align: center; + color: #999; + font-size: 0.875rem; +} + +.gesprek-chat__history-list { + overflow-y: auto; + max-height: 320px; +} + +.gesprek-chat__history-item { + display: block; + width: 100%; + padding: 10px 14px; + background: none; + border: none; + text-align: left; + font-size: 0.875rem; + cursor: pointer; + border-bottom: 1px solid var(--border-color, #e5e5e5); + transition: background-color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gesprek-chat__history-item:hover { + background: var(--bg-secondary, #f8f9fa); +} + +.gesprek-chat__history-item:last-child { + border-bottom: none; +} + +/* Powered by badge */ +.gesprek-chat__powered-by { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.6875rem; + color: #999; + margin-left: 8px; +} + +/* ============================================================================ + Messages + ============================================================================ */ + +.gesprek-chat__messages { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Welcome state */ +.gesprek-chat__welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 24px; + max-width: 600px; + margin: 0 auto; +} + +.gesprek-chat__welcome-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.gesprek-chat__welcome-header h2 { + font-size: 1.5rem; + font-weight: 600; + margin: 0; + color: var(--primary-blue, #154273); +} + +.gesprek-chat__welcome-icon { + color: #ffd700; +} + +.gesprek-chat__welcome-description { + font-size: 1rem; + color: #666; + line-height: 1.6; + margin: 0 0 24px 0; +} + +/* Example questions */ +.gesprek-chat__examples { + width: 100%; +} + +.gesprek-chat__examples-title { + font-size: 0.875rem; + font-weight: 500; + color: #666; + display: block; + margin-bottom: 12px; +} + +.gesprek-chat__examples-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.gesprek-chat__example-btn { + padding: 12px 16px; + background: var(--bg-secondary, #f8f9fa); + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 8px; + font-size: 0.875rem; + text-align: left; + cursor: pointer; + transition: all 0.2s; +} + +.gesprek-chat__example-btn:hover { + background: var(--light-blue, #e8f4fc); + border-color: var(--primary-blue, #154273); + color: var(--primary-blue, #154273); +} + +/* Message bubbles */ +.gesprek-message { + display: flex; + max-width: 85%; +} + +.gesprek-message--user { + align-self: flex-end; +} + +.gesprek-message--assistant { + align-self: flex-start; +} + +.gesprek-message__content { + padding: 12px 16px; + border-radius: 12px; + font-size: 0.9375rem; + line-height: 1.6; +} + +.gesprek-message--user .gesprek-message__content { + background: var(--primary-blue, #154273); + color: white; + border-bottom-right-radius: 4px; +} + +.gesprek-message--assistant .gesprek-message__content { + background: var(--bg-secondary, #f8f9fa); + color: var(--text-primary, #333); + border-bottom-left-radius: 4px; +} + +.gesprek-message__text { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +.gesprek-message__loading { + display: flex; + align-items: center; + gap: 8px; + color: #666; +} + +.gesprek-message__loading-icon { + animation: spin 1s linear infinite; +} + +.gesprek-message__error { + display: flex; + align-items: center; + gap: 8px; + color: #dc3545; +} + +/* Query boxes */ +.gesprek-message__query-box { + margin-top: 12px; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 8px; + overflow: hidden; +} + +.gesprek-message__query-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--bg-secondary, #f8f9fa); + border-bottom: 1px solid var(--border-color, #e5e5e5); + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #666; +} + +.gesprek-message__query-copy { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: white; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 4px; + font-size: 0.6875rem; + cursor: pointer; + transition: all 0.2s; +} + +.gesprek-message__query-copy:hover { + background: var(--primary-blue, #154273); + color: white; + border-color: var(--primary-blue, #154273); +} + +.gesprek-message__query-code { + margin: 0; + padding: 12px; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.8125rem; + line-height: 1.5; + overflow-x: auto; + background: #1e1e1e; + color: #d4d4d4; +} + +.gesprek-message__query-code code { + font-family: inherit; +} + +/* Meta info */ +.gesprek-message__meta { + display: flex; + gap: 16px; + margin-top: 8px; + font-size: 0.75rem; + color: #999; +} + +.gesprek-message__confidence { + display: flex; + align-items: center; + gap: 4px; +} + +.gesprek-message__results { + display: flex; + align-items: center; + gap: 4px; +} + +/* ============================================================================ + Visualization Panel + ============================================================================ */ + +.gesprek-viz-panel { + background: white; + border-left: 1px solid var(--border-color, #e5e5e5); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.gesprek-viz-panel--expanded { + position: fixed; + inset: 64px 0 0 0; + z-index: 100; + border-left: none; +} + +.gesprek-viz { + display: flex; + flex-direction: column; + height: 100%; +} + +.gesprek-viz--empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + color: #999; + padding: 48px; + text-align: center; +} + +.gesprek-viz--expanded { + height: 100%; +} + +/* Visualization toolbar */ +.gesprek-viz__toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color, #e5e5e5); + background: var(--bg-secondary, #f8f9fa); +} + +.gesprek-viz__type-selector { + display: flex; + gap: 4px; +} + +.gesprek-viz__type-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: white; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 6px; + color: #666; + cursor: pointer; + transition: all 0.2s; +} + +.gesprek-viz__type-btn:hover { + background: var(--bg-secondary, #f8f9fa); + border-color: var(--primary-blue, #154273); + color: var(--primary-blue, #154273); +} + +.gesprek-viz__type-btn--active { + background: var(--primary-blue, #154273); + border-color: var(--primary-blue, #154273); + color: white; +} + +.gesprek-viz__type-btn--active:hover { + background: #1a5490; + color: white; +} + +.gesprek-viz__actions { + display: flex; + gap: 4px; +} + +.gesprek-viz__action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: white; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 6px; + color: #666; + cursor: pointer; + transition: all 0.2s; +} + +.gesprek-viz__action-btn:hover { + background: var(--bg-secondary, #f8f9fa); + border-color: var(--primary-blue, #154273); + color: var(--primary-blue, #154273); +} + +/* Visualization content */ +.gesprek-viz__content { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.gesprek-viz__footer { + padding: 12px 16px; + border-top: 1px solid var(--border-color, #e5e5e5); + font-size: 0.8125rem; + color: #666; + text-align: center; +} + +/* Cards grid */ +.gesprek-viz__cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +/* Table */ +.gesprek-viz__table-container { + overflow-x: auto; +} + +.gesprek-viz__table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.gesprek-viz__table th, +.gesprek-viz__table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border-color, #e5e5e5); +} + +.gesprek-viz__table th { + background: var(--bg-secondary, #f8f9fa); + font-weight: 600; + color: var(--text-primary, #333); +} + +.gesprek-viz__table tr:hover td { + background: var(--light-blue, #e8f4fc); +} + +/* Map placeholder */ +.gesprek-viz__map { + height: 100%; + min-height: 300px; +} + +.gesprek-viz__map-placeholder { + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: linear-gradient(135deg, #e8f4fc 0%, #f8f9fa 100%); + border-radius: 8px; + color: var(--primary-blue, #154273); +} + +.gesprek-viz__map-placeholder p { + margin: 0; + font-size: 1rem; + font-weight: 500; +} + +.gesprek-viz__map-hint { + font-size: 0.8125rem !important; + font-weight: 400 !important; + color: #666 !important; +} + +/* Chart/Timeline/Network placeholders */ +.gesprek-viz__chart, +.gesprek-viz__timeline, +.gesprek-viz__network { + height: 100%; + min-height: 300px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: linear-gradient(135deg, #e8f4fc 0%, #f8f9fa 100%); + border-radius: 8px; + color: var(--primary-blue, #154273); +} + +/* ============================================================================ + Institution Cards + ============================================================================ */ + +.gesprek-institution-card { + background: white; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 10px; + padding: 16px; + transition: all 0.2s; +} + +.gesprek-institution-card:hover { + border-color: var(--primary-blue, #154273); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.gesprek-institution-card__header { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 10px; +} + +.gesprek-institution-card__icon { + color: var(--primary-blue, #154273); + flex-shrink: 0; + margin-top: 2px; +} + +.gesprek-institution-card__name { + flex: 1; + font-size: 1rem; + font-weight: 600; + margin: 0; + color: var(--text-primary, #333); + line-height: 1.3; +} + +.gesprek-institution-card__type { + padding: 3px 8px; + background: var(--light-blue, #e8f4fc); + color: var(--primary-blue, #154273); + border-radius: 4px; + font-size: 0.6875rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.3px; + white-space: nowrap; +} + +.gesprek-institution-card__description { + font-size: 0.8125rem; + color: #666; + line-height: 1.5; + margin: 0 0 12px 0; +} + +.gesprek-institution-card__details { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.gesprek-institution-card__detail { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; + color: #666; +} + +.gesprek-institution-card__detail svg { + color: #999; + flex-shrink: 0; +} + +.gesprek-institution-card__detail--rating { + color: var(--text-primary, #333); +} + +.gesprek-institution-card__stars { + color: #ffc107; + letter-spacing: -1px; +} + +.gesprek-institution-card__reviews { + color: #999; + font-size: 0.75rem; +} + +.gesprek-institution-card__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.gesprek-institution-card__action { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + background: var(--bg-secondary, #f8f9fa); + border: 1px solid var(--border-color, #e5e5e5); + border-radius: 6px; + font-size: 0.75rem; + color: var(--text-primary, #333); + text-decoration: none; + cursor: pointer; + transition: all 0.2s; +} + +.gesprek-institution-card__action:hover { + background: var(--light-blue, #e8f4fc); + border-color: var(--primary-blue, #154273); + color: var(--primary-blue, #154273); +} + +.gesprek-institution-card__ids { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border-color, #e5e5e5); + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.gesprek-institution-card__id { + font-size: 0.6875rem; + color: #999; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; +} + +.gesprek-institution-card__id--link { + color: var(--primary-blue, #154273); + text-decoration: none; +} + +.gesprek-institution-card__id--link:hover { + text-decoration: underline; +} + +/* ============================================================================ + Dark Mode Support + ============================================================================ */ + +[data-theme="dark"] .gesprek-page { + background-color: var(--bg-secondary); +} + +[data-theme="dark"] .gesprek-chat { + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .gesprek-chat__input-container { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .gesprek-chat__input { + color: var(--text-primary); +} + +[data-theme="dark"] .gesprek-chat__model-btn { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .gesprek-chat__model-dropdown, +[data-theme="dark"] .gesprek-chat__history-dropdown { + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .gesprek-chat__action-btn { + background: var(--bg-secondary); + border-color: var(--border-color); + color: var(--text-secondary); +} + +[data-theme="dark"] .gesprek-message--assistant .gesprek-message__content { + background: var(--bg-secondary); + color: var(--text-primary); +} + +[data-theme="dark"] .gesprek-chat__example-btn { + background: var(--bg-secondary); + border-color: var(--border-color); + color: var(--text-primary); +} + +[data-theme="dark"] .gesprek-viz-panel { + background: var(--bg-primary); + border-color: var(--border-color); +} + +[data-theme="dark"] .gesprek-viz__toolbar { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .gesprek-viz__type-btn, +[data-theme="dark"] .gesprek-viz__action-btn { + background: var(--bg-primary); + border-color: var(--border-color); + color: var(--text-secondary); +} + +[data-theme="dark"] .gesprek-institution-card { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +[data-theme="dark"] .gesprek-institution-card__name { + color: var(--text-primary); +} + +[data-theme="dark"] .gesprek-viz__table th { + background: var(--bg-secondary); + color: var(--text-primary); +} + +[data-theme="dark"] .gesprek-viz__table td { + border-color: var(--border-color); +} + +/* ============================================================================ + Responsive Adjustments + ============================================================================ */ + +@media (max-width: 768px) { + .gesprek-chat__header { + padding: 12px 16px; + } + + .gesprek-chat__title h1 { + font-size: 1.125rem; + } + + .gesprek-chat__input-area { + padding: 12px 16px; + } + + .gesprek-chat__toolbar { + flex-wrap: wrap; + } + + .gesprek-chat__model-btn { + font-size: 0.75rem; + padding: 4px 8px; + } + + .gesprek-chat__messages { + padding: 16px; + } + + .gesprek-message { + max-width: 95%; + } + + .gesprek-chat__welcome { + padding: 24px 16px; + } + + .gesprek-chat__welcome-header h2 { + font-size: 1.25rem; + } + + .gesprek-viz__cards { + grid-template-columns: 1fr; + } +} + +/* ============================================================================ + D3 Visualization Components + ============================================================================ */ + +/* Geo Map Component */ +.gesprek-geo-map { + position: relative; + background: #f8fafc; + border-radius: 8px; + overflow: hidden; +} + +.gesprek-geo-map--loading, +.gesprek-geo-map--error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: 12px; + color: var(--text-secondary, #64748b); +} + +.gesprek-geo-map__loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.gesprek-geo-map__spinner { + width: 32px; + height: 32px; + border: 3px solid #e2e8f0; + border-top-color: var(--primary-blue, #154273); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.gesprek-geo-map__tooltip { + background: white; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + font-size: 12px; + max-width: 200px; +} + +.gesprek-geo-map__tooltip-name { + font-weight: 600; + color: var(--text-primary, #1e293b); + margin-bottom: 2px; +} + +.gesprek-geo-map__tooltip-type { + font-size: 11px; + color: var(--primary-blue, #154273); + margin-bottom: 2px; +} + +.gesprek-geo-map__tooltip-city { + font-size: 11px; + color: var(--text-secondary, #64748b); +} + +.gesprek-geo-map__tooltip-rating { + margin-top: 4px; + font-size: 11px; + color: #f59e0b; +} + +.gesprek-geo-map__controls { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 4px; +} + +.gesprek-geo-map__control-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: white; + border: 1px solid #e2e8f0; + border-radius: 6px; + cursor: pointer; + color: var(--text-secondary, #64748b); + transition: all 0.2s; +} + +.gesprek-geo-map__control-btn:hover { + background: #f1f5f9; + color: var(--primary-blue, #154273); +} + +.gesprek-geo-map__legend { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(255, 255, 255, 0.95); + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 8px 12px; + font-size: 11px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.gesprek-geo-map__legend-item { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary, #64748b); +} + +.gesprek-geo-map__legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.gesprek-geo-map__stats { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(255, 255, 255, 0.95); + border: 1px solid #e2e8f0; + border-radius: 4px; + padding: 4px 8px; + font-size: 10px; + color: var(--text-secondary, #64748b); +} + +/* Bar Chart Component */ +.gesprek-bar-chart { + position: relative; +} + +.gesprek-bar-chart--empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + color: var(--text-secondary, #64748b); +} + +/* Timeline Component */ +.gesprek-timeline { + position: relative; +} + +.gesprek-timeline--empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 150px; + color: var(--text-secondary, #64748b); +} + +/* Network Graph Component */ +.gesprek-network-graph { + position: relative; +} + +.gesprek-network-graph--empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 200px; + color: var(--text-secondary, #64748b); +} + +/* Placeholder styles for empty visualizations */ +.gesprek-viz__map-placeholder, +.gesprek-viz__chart-placeholder, +.gesprek-viz__timeline-placeholder, +.gesprek-viz__network-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 250px; + color: var(--text-secondary, #64748b); + gap: 12px; + text-align: center; + padding: 20px; +} + +.gesprek-viz__map-placeholder svg, +.gesprek-viz__chart-placeholder svg, +.gesprek-viz__timeline-placeholder svg, +.gesprek-viz__network-placeholder svg { + opacity: 0.5; +} + +/* Dark theme for D3 components */ +[data-theme="dark"] .gesprek-geo-map { + background: var(--bg-secondary, #1e293b); +} + +[data-theme="dark"] .gesprek-geo-map__tooltip, +[data-theme="dark"] .gesprek-geo-map__legend, +[data-theme="dark"] .gesprek-geo-map__stats, +[data-theme="dark"] .gesprek-geo-map__control-btn { + background: var(--bg-secondary, #1e293b); + border-color: var(--border-color, #334155); + color: var(--text-secondary, #94a3b8); +} + +[data-theme="dark"] .gesprek-geo-map__tooltip-name { + color: var(--text-primary, #f1f5f9); +} + +[data-theme="dark"] .gesprek-geo-map__control-btn:hover { + background: var(--bg-tertiary, #334155); +} diff --git a/frontend/src/pages/GesprekPage.tsx b/frontend/src/pages/GesprekPage.tsx new file mode 100644 index 0000000000..b602558b8d --- /dev/null +++ b/frontend/src/pages/GesprekPage.tsx @@ -0,0 +1,1075 @@ +/** + * GesprekPage.tsx - Conversational Heritage Institution Explorer + * + * A full-featured conversation page that integrates: + * - DSPy RAG pipeline for intelligent responses + * - Multi-database retrieval (Qdrant, Oxigraph, TypeDB) + * - Interactive D3 geo visualizations + * - Dynamic visualization components (maps, timelines, charts) + * - Bilingual support (NL/EN) + * + * Based on the NDEStatsPage and QueryBuilderPage patterns. + * + * © 2025 Netwerk Digitaal Erfgoed & TextPast. All rights reserved. + */ + +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import { + Send, + Loader2, + Sparkles, + AlertCircle, + Copy, + Check, + ChevronDown, + History, + Download, + Upload, + Trash2, + X, + Map, + BarChart3, + Network, + Clock, + Table2, + LayoutGrid, + Maximize2, + Minimize2, + MapPin, + Building2, + Info, + ExternalLink, +} from 'lucide-react'; +import { useLanguage } from '../contexts/LanguageContext'; +import { useMultiDatabaseRAG, type RAGResponse, type ConversationMessage, type VisualizationType, type InstitutionData } from '../hooks/useMultiDatabaseRAG'; +import { GesprekGeoMap, GesprekBarChart, GesprekTimeline, GesprekNetworkGraph } from '../components/gesprek'; +import './GesprekPage.css'; + +// ============================================================================ +// Constants +// ============================================================================ + +// Available Claude models (updated December 2025) +const CLAUDE_MODELS = [ + { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', description: 'Best balance of speed and quality' }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', description: 'Fastest, near-frontier intelligence' }, + { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', description: 'Most intelligent, complex tasks' }, +]; + +// LocalStorage keys +const STORAGE_KEYS = { + HISTORY: 'glam-gesprek-history', + CONVERSATIONS: 'glam-gesprek-saved', +}; + +// Bilingual text content +const TEXT = { + pageTitle: { nl: 'Gesprek', en: 'Conversation' }, + pageSubtitle: { + nl: 'Verken erfgoedinstellingen via een natuurlijk gesprek', + en: 'Explore heritage institutions through natural conversation' + }, + placeholder: { + nl: 'Stel een vraag over erfgoedinstellingen...', + en: 'Ask a question about heritage institutions...' + }, + send: { nl: 'Verstuur', en: 'Send' }, + thinking: { nl: 'Bezig met nadenken...', en: 'Thinking...' }, + searching: { nl: 'Zoeken in databases...', en: 'Searching databases...' }, + generatedQuery: { nl: 'Gegenereerde Query', en: 'Generated Query' }, + sparqlQuery: { nl: 'SPARQL Query', en: 'SPARQL Query' }, + typeqlQuery: { nl: 'TypeQL Query', en: 'TypeQL Query' }, + copied: { nl: 'Gekopieerd!', en: 'Copied!' }, + copyQuery: { nl: 'Kopieer', en: 'Copy' }, + errorTitle: { nl: 'Fout', en: 'Error' }, + errorConnection: { + nl: 'Kan geen verbinding maken met de databases. Controleer of de services draaien.', + en: 'Cannot connect to databases. Check if services are running.' + }, + welcomeTitle: { nl: 'Erfgoed Gesprek', en: 'Heritage Conversation' }, + welcomeDescription: { + nl: 'Stel vragen over erfgoedinstellingen in Nederland en wereldwijd. Ik doorzoek meerdere databases en toon resultaten met interactieve visualisaties.', + en: 'Ask questions about heritage institutions in the Netherlands and worldwide. I search multiple databases and show results with interactive visualizations.' + }, + exampleQuestions: { nl: 'Probeer:', en: 'Try:' }, + poweredBy: { nl: 'DSPy + Qdrant + Oxigraph', en: 'DSPy + Qdrant + Oxigraph' }, + selectModel: { nl: 'Model', en: 'Model' }, + history: { nl: 'Geschiedenis', en: 'History' }, + clearHistory: { nl: 'Wis', en: 'Clear' }, + noHistory: { nl: 'Geen recente vragen', en: 'No recent questions' }, + exportConversation: { nl: 'Exporteren', en: 'Export' }, + importConversation: { nl: 'Importeren', en: 'Import' }, + clearConversation: { nl: 'Wissen', en: 'Clear' }, + conversationCleared: { nl: 'Conversatie gewist', en: 'Conversation cleared' }, + exportSuccess: { nl: 'Conversatie geëxporteerd', en: 'Conversation exported' }, + importSuccess: { nl: 'Conversatie geïmporteerd', en: 'Conversation imported' }, + importError: { nl: 'Ongeldig bestand', en: 'Invalid file' }, + resultsFound: { nl: 'resultaten gevonden', en: 'results found' }, + sources: { nl: 'Bronnen', en: 'Sources' }, + showOnMap: { nl: 'Toon op kaart', en: 'Show on map' }, + showChart: { nl: 'Toon grafiek', en: 'Show chart' }, + showTimeline: { nl: 'Toon tijdlijn', en: 'Show timeline' }, + showNetwork: { nl: 'Toon netwerk', en: 'Show network' }, + showTable: { nl: 'Toon tabel', en: 'Show table' }, + showCards: { nl: 'Toon kaarten', en: 'Show cards' }, + expandVisualization: { nl: 'Vergroot', en: 'Expand' }, + collapseVisualization: { nl: 'Verklein', en: 'Collapse' }, + refreshVisualization: { nl: 'Ververs', en: 'Refresh' }, + noVisualization: { nl: 'Geen visualisatie beschikbaar', en: 'No visualization available' }, + institutions: { nl: 'Instellingen', en: 'Institutions' }, + viewDetails: { nl: 'Details bekijken', en: 'View details' }, + openWebsite: { nl: 'Website openen', en: 'Open website' }, + confidence: { nl: 'Betrouwbaarheid', en: 'Confidence' }, +}; + +// Example questions +const EXAMPLE_QUESTIONS = { + nl: [ + 'Toon alle musea in Amsterdam op de kaart', + 'Welke archieven zijn er in Noord-Holland?', + 'Hoeveel bibliotheken zijn er per provincie?', + 'Wat zijn de best beoordeelde musea?', + 'Toon de tijdlijn van opgerichte instellingen', + ], + en: [ + 'Show all museums in Amsterdam on the map', + 'What archives are in North Holland?', + 'How many libraries are there per province?', + 'What are the best rated museums?', + 'Show the timeline of founded institutions', + ], +}; + +// Visualization type icons +const VIZ_ICONS: Record<VisualizationType, React.ReactNode> = { + none: null, + map: <Map size={16} />, + chart: <BarChart3 size={16} />, + timeline: <Clock size={16} />, + network: <Network size={16} />, + table: <Table2 size={16} />, + card: <LayoutGrid size={16} />, + gallery: <Building2 size={16} />, +}; + +// ============================================================================ +// Sub-Components +// ============================================================================ + +interface InstitutionCardProps { + institution: InstitutionData; + language: 'nl' | 'en'; +} + +const InstitutionCard: React.FC<InstitutionCardProps> = ({ institution, language }) => { + const t = (key: keyof typeof TEXT) => TEXT[key][language]; + + return ( + <div className="gesprek-institution-card"> + <div className="gesprek-institution-card__header"> + <Building2 size={20} className="gesprek-institution-card__icon" /> + <h4 className="gesprek-institution-card__name">{institution.name}</h4> + {institution.type && ( + <span className="gesprek-institution-card__type">{institution.type}</span> + )} + </div> + + {institution.description && ( + <p className="gesprek-institution-card__description"> + {institution.description.slice(0, 150)} + {institution.description.length > 150 ? '...' : ''} + </p> + )} + + <div className="gesprek-institution-card__details"> + {institution.city && ( + <div className="gesprek-institution-card__detail"> + <MapPin size={14} /> + <span>{institution.city}{institution.province ? `, ${institution.province}` : ''}</span> + </div> + )} + + {(institution.rating ?? 0) > 0 && ( + <div className="gesprek-institution-card__detail gesprek-institution-card__detail--rating"> + <span className="gesprek-institution-card__stars"> + {'★'.repeat(Math.round(institution.rating ?? 0))} + {'☆'.repeat(5 - Math.round(institution.rating ?? 0))} + </span> + <span>{(institution.rating ?? 0).toFixed(1)}</span> + {(institution.reviews ?? 0) > 0 && ( + <span className="gesprek-institution-card__reviews"> + ({institution.reviews} reviews) + </span> + )} + </div> + )} + </div> + + <div className="gesprek-institution-card__actions"> + {institution.website && ( + <a + href={institution.website} + target="_blank" + rel="noopener noreferrer" + className="gesprek-institution-card__action" + > + <ExternalLink size={14} /> + {t('openWebsite')} + </a> + )} + {(institution.latitude && institution.longitude) && ( + <button className="gesprek-institution-card__action"> + <Map size={14} /> + {t('showOnMap')} + </button> + )} + </div> + + {(institution.isil || institution.wikidata) && ( + <div className="gesprek-institution-card__ids"> + {institution.isil && ( + <span className="gesprek-institution-card__id">ISIL: {institution.isil}</span> + )} + {institution.wikidata && ( + <a + href={`https://www.wikidata.org/wiki/${institution.wikidata}`} + target="_blank" + rel="noopener noreferrer" + className="gesprek-institution-card__id gesprek-institution-card__id--link" + > + Wikidata: {institution.wikidata} + </a> + )} + </div> + )} + </div> + ); +}; + +interface VisualizationPanelProps { + type: VisualizationType; + data: RAGResponse['visualizationData']; + language: 'nl' | 'en'; + isExpanded: boolean; + onToggleExpand: () => void; + onChangeType: (type: VisualizationType) => void; +} + +const VisualizationPanel: React.FC<VisualizationPanelProps> = ({ + type, + data, + language, + isExpanded, + onToggleExpand, + onChangeType, +}) => { + const t = (key: keyof typeof TEXT) => TEXT[key][language]; + const [selectedInstitutionId, setSelectedInstitutionId] = useState<string | null>(null); + + // Calculate dimensions based on expanded state + const vizWidth = isExpanded ? Math.min(window.innerWidth - 100, 1200) : 550; + const vizHeight = isExpanded ? 500 : 350; + + // Convert institutions to GeoCoordinates for map + const mapCoordinates = useMemo(() => { + if (!data?.coordinates) { + // Fallback: convert institutions with lat/lng to coordinates + return (data?.institutions || []) + .filter(inst => inst.latitude && inst.longitude) + .map(inst => ({ + lat: inst.latitude!, + lng: inst.longitude!, + label: inst.name, + type: inst.type, + data: inst, + })); + } + return data.coordinates; + }, [data?.coordinates, data?.institutions]); + + // Generate chart data from institutions if not provided + const chartData = useMemo(() => { + if (data?.chartData) return data.chartData; + + // Group by type or province + const institutions = data?.institutions || []; + const byType: Record<string, number> = {}; + const byProvince: Record<string, number> = {}; + + institutions.forEach(inst => { + if (inst.type) { + byType[inst.type] = (byType[inst.type] || 0) + 1; + } + if (inst.province) { + byProvince[inst.province] = (byProvince[inst.province] || 0) + 1; + } + }); + + // Use province if more variety, else type + const dataMap = Object.keys(byProvince).length >= 3 ? byProvince : byType; + const labels = Object.keys(dataMap).slice(0, 10); + const values = labels.map(l => dataMap[l]); + + return { + labels, + datasets: [{ + label: language === 'nl' ? 'Aantal' : 'Count', + data: values, + }], + }; + }, [data?.chartData, data?.institutions, language]); + + // Generate timeline events from institutions if not provided + const timelineEvents = useMemo(() => { + if (data?.timeline) return data.timeline; + + // Could extract founding dates from institutions if available + return []; + }, [data?.timeline]); + + // Handle marker/node clicks + const handleInstitutionClick = useCallback((inst: InstitutionData) => { + setSelectedInstitutionId(inst.id); + console.log('Selected institution:', inst); + }, []); + + if (!data || type === 'none') { + return ( + <div className="gesprek-viz gesprek-viz--empty"> + <Info size={24} /> + <p>{t('noVisualization')}</p> + </div> + ); + } + + const renderVisualization = () => { + switch (type) { + case 'map': + return ( + <div className="gesprek-viz__map"> + {mapCoordinates.length > 0 ? ( + <GesprekGeoMap + coordinates={mapCoordinates} + width={vizWidth} + height={vizHeight} + language={language} + selectedId={selectedInstitutionId} + onMarkerClick={handleInstitutionClick} + showClustering={mapCoordinates.length > 50} + /> + ) : ( + <div className="gesprek-viz__map-placeholder"> + <Map size={48} /> + <p>{data.institutions?.length || 0} {t('institutions')}</p> + <p className="gesprek-viz__map-hint"> + {language === 'nl' + ? 'Geen locatiegegevens beschikbaar' + : 'No location data available'} + </p> + </div> + )} + </div> + ); + + case 'card': + return ( + <div className="gesprek-viz__cards"> + {data.institutions?.slice(0, 12).map((inst, idx) => ( + <InstitutionCard + key={inst.id || idx} + institution={inst} + language={language} + /> + ))} + </div> + ); + + case 'table': + return ( + <div className="gesprek-viz__table-container"> + <table className="gesprek-viz__table"> + <thead> + <tr> + <th>{language === 'nl' ? 'Naam' : 'Name'}</th> + <th>{language === 'nl' ? 'Type' : 'Type'}</th> + <th>{language === 'nl' ? 'Plaats' : 'City'}</th> + <th>{language === 'nl' ? 'Provincie' : 'Province'}</th> + </tr> + </thead> + <tbody> + {data.institutions?.map((inst, idx) => ( + <tr key={inst.id || idx}> + <td>{inst.name}</td> + <td>{inst.type || '-'}</td> + <td>{inst.city || '-'}</td> + <td>{inst.province || '-'}</td> + </tr> + ))} + </tbody> + </table> + </div> + ); + + case 'chart': + return ( + <div className="gesprek-viz__chart"> + {chartData.labels.length > 0 ? ( + <GesprekBarChart + data={chartData} + width={vizWidth} + height={vizHeight} + language={language} + orientation="vertical" + showValues={true} + animate={true} + title={language === 'nl' ? 'Verdeling' : 'Distribution'} + /> + ) : ( + <div className="gesprek-viz__chart-placeholder"> + <BarChart3 size={48} /> + <p>{language === 'nl' ? 'Geen grafiekgegevens beschikbaar' : 'No chart data available'}</p> + </div> + )} + </div> + ); + + case 'timeline': + return ( + <div className="gesprek-viz__timeline"> + {timelineEvents.length > 0 ? ( + <GesprekTimeline + events={timelineEvents} + width={vizWidth} + height={Math.min(vizHeight, 250)} + language={language} + showLabels={true} + /> + ) : ( + <div className="gesprek-viz__timeline-placeholder"> + <Clock size={48} /> + <p>{language === 'nl' ? 'Geen tijdlijngegevens beschikbaar' : 'No timeline data available'}</p> + </div> + )} + </div> + ); + + case 'network': + return ( + <div className="gesprek-viz__network"> + {data.graphData && data.graphData.nodes.length > 0 ? ( + <GesprekNetworkGraph + data={data.graphData} + width={vizWidth} + height={vizHeight} + language={language} + showLabels={true} + showEdgeLabels={data.graphData.edges.length < 20} + /> + ) : ( + <div className="gesprek-viz__network-placeholder"> + <Network size={48} /> + <p>{language === 'nl' ? 'Geen netwerkgegevens beschikbaar' : 'No network data available'}</p> + </div> + )} + </div> + ); + + default: + return null; + } + }; + + return ( + <div className={`gesprek-viz ${isExpanded ? 'gesprek-viz--expanded' : ''}`}> + <div className="gesprek-viz__toolbar"> + <div className="gesprek-viz__type-selector"> + {(['map', 'card', 'table', 'chart', 'timeline', 'network'] as VisualizationType[]).map(vizType => ( + <button + key={vizType} + className={`gesprek-viz__type-btn ${type === vizType ? 'gesprek-viz__type-btn--active' : ''}`} + onClick={() => onChangeType(vizType)} + title={t(`show${vizType.charAt(0).toUpperCase() + vizType.slice(1)}` as keyof typeof TEXT)} + > + {VIZ_ICONS[vizType]} + </button> + ))} + </div> + + <div className="gesprek-viz__actions"> + <button + className="gesprek-viz__action-btn" + onClick={onToggleExpand} + title={isExpanded ? t('collapseVisualization') : t('expandVisualization')} + > + {isExpanded ? <Minimize2 size={16} /> : <Maximize2 size={16} />} + </button> + </div> + </div> + + <div className="gesprek-viz__content"> + {renderVisualization()} + </div> + + {data.institutions && data.institutions.length > 0 && ( + <div className="gesprek-viz__footer"> + <span>{data.institutions.length} {t('resultsFound')}</span> + </div> + )} + </div> + ); +}; + +// ============================================================================ +// Main Page Component +// ============================================================================ + +const GesprekPage: React.FC = () => { + const { language } = useLanguage(); + const t = (key: keyof typeof TEXT) => TEXT[key][language]; + + // RAG hook + const { queryRAG, isLoading: ragLoading, clearContext } = useMultiDatabaseRAG(); + + // State + const [messages, setMessages] = useState<ConversationMessage[]>([]); + const [input, setInput] = useState(''); + const [selectedModel, setSelectedModel] = useState(CLAUDE_MODELS[0].id); + const [showModelDropdown, setShowModelDropdown] = useState(false); + const [showHistoryDropdown, setShowHistoryDropdown] = useState(false); + const [history, setHistory] = useState<Array<{ question: string; timestamp: Date }>>([]); + const [notification, setNotification] = useState<string | null>(null); + const [copiedId, setCopiedId] = useState<string | null>(null); + const [vizExpanded, setVizExpanded] = useState(false); + const [activeVizType, setActiveVizType] = useState<VisualizationType>('card'); + const [activeVizData, setActiveVizData] = useState<RAGResponse['visualizationData'] | null>(null); + + // Refs + const messagesEndRef = useRef<HTMLDivElement>(null); + const inputRef = useRef<HTMLTextAreaElement>(null); + const modelDropdownRef = useRef<HTMLDivElement>(null); + const historyDropdownRef = useRef<HTMLDivElement>(null); + const fileInputRef = useRef<HTMLInputElement>(null); + + // ============================================================================ + // Effects + // ============================================================================ + + // Load history from localStorage + useEffect(() => { + try { + const savedHistory = localStorage.getItem(STORAGE_KEYS.HISTORY); + if (savedHistory) { + const parsed = JSON.parse(savedHistory); + setHistory(parsed.map((h: { question: string; timestamp: string }) => ({ + ...h, + timestamp: new Date(h.timestamp) + }))); + } + } catch (e) { + console.error('Failed to load history:', e); + } + }, []); + + // Auto-scroll to bottom + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Auto-resize textarea + useEffect(() => { + if (inputRef.current) { + inputRef.current.style.height = 'auto'; + inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 150)}px`; + } + }, [input]); + + // Close dropdowns on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) { + setShowModelDropdown(false); + } + if (historyDropdownRef.current && !historyDropdownRef.current.contains(e.target as Node)) { + setShowHistoryDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // ============================================================================ + // Handlers + // ============================================================================ + + const showNotification = useCallback((message: string) => { + setNotification(message); + setTimeout(() => setNotification(null), 3000); + }, []); + + const saveHistory = useCallback((items: Array<{ question: string; timestamp: Date }>) => { + try { + const trimmed = items.slice(-20); + localStorage.setItem(STORAGE_KEYS.HISTORY, JSON.stringify(trimmed)); + setHistory(trimmed); + } catch (e) { + console.error('Failed to save history:', e); + } + }, []); + + const handleSend = async () => { + const question = input.trim(); + if (!question || ragLoading) return; + + const userMessage: ConversationMessage = { + id: `msg-${Date.now()}`, + role: 'user', + content: question, + timestamp: new Date(), + }; + + const loadingMessage: ConversationMessage = { + id: `msg-${Date.now()}-loading`, + role: 'assistant', + content: t('searching'), + timestamp: new Date(), + isLoading: true, + }; + + setMessages(prev => [...prev, userMessage, loadingMessage]); + setInput(''); + + try { + const response = await queryRAG(question, { + model: selectedModel, + language, + conversationHistory: messages, + }); + + // Save to history + saveHistory([...history, { question, timestamp: new Date() }]); + + // Update visualization + if (response.visualizationType && response.visualizationType !== 'none') { + setActiveVizType(response.visualizationType); + setActiveVizData(response.visualizationData || null); + } + + // Replace loading message with response + setMessages(prev => prev.map(msg => + msg.id === loadingMessage.id + ? { + ...msg, + content: response.answer, + response, + isLoading: false, + } + : msg + )); + + } catch (error) { + setMessages(prev => prev.map(msg => + msg.id === loadingMessage.id + ? { + ...msg, + content: t('errorConnection'), + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + } + : msg + )); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleExampleClick = (question: string) => { + setInput(question); + inputRef.current?.focus(); + }; + + const handleCopyQuery = async (id: string, query: string) => { + await navigator.clipboard.writeText(query); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + }; + + const handleHistoryClick = (item: { question: string }) => { + setInput(item.question); + setShowHistoryDropdown(false); + inputRef.current?.focus(); + }; + + const handleClearHistory = () => { + localStorage.removeItem(STORAGE_KEYS.HISTORY); + setHistory([]); + setShowHistoryDropdown(false); + }; + + const handleExportConversation = () => { + if (messages.length === 0) return; + + const exportData = { + version: 1, + exportedAt: new Date().toISOString(), + model: selectedModel, + messages: messages.map(m => ({ + role: m.role, + content: m.content, + timestamp: m.timestamp, + response: m.response, + })), + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `gesprek-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + showNotification(t('exportSuccess')); + }; + + const handleImportConversation = (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const data = JSON.parse(event.target?.result as string); + if (data.version && data.messages && Array.isArray(data.messages)) { + const importedMessages: ConversationMessage[] = data.messages.map( + (m: ConversationMessage, i: number) => ({ + id: `imported-${Date.now()}-${i}`, + role: m.role, + content: m.content, + timestamp: new Date(m.timestamp), + response: m.response, + }) + ); + setMessages(importedMessages); + if (data.model) { + setSelectedModel(data.model); + } + showNotification(t('importSuccess')); + } else { + showNotification(t('importError')); + } + } catch { + showNotification(t('importError')); + } + }; + reader.readAsText(file); + e.target.value = ''; + }; + + const handleClearConversation = () => { + setMessages([]); + clearContext(); + setActiveVizData(null); + showNotification(t('conversationCleared')); + }; + + // ============================================================================ + // Render + // ============================================================================ + + return ( + <div className="gesprek-page"> + {/* Notification Toast */} + {notification && ( + <div className="gesprek-notification"> + <span>{notification}</span> + <button onClick={() => setNotification(null)} className="gesprek-notification__close"> + <X size={14} /> + </button> + </div> + )} + + {/* Hidden file input */} + <input + type="file" + ref={fileInputRef} + accept=".json" + onChange={handleImportConversation} + style={{ display: 'none' }} + /> + + {/* Main Layout */} + <div className="gesprek-layout"> + {/* Chat Panel */} + <div className="gesprek-chat"> + {/* Header */} + <div className="gesprek-chat__header"> + <div className="gesprek-chat__title"> + <Sparkles size={24} className="gesprek-chat__icon" /> + <div> + <h1>{t('pageTitle')}</h1> + <p>{t('pageSubtitle')}</p> + </div> + </div> + </div> + + {/* Input Area - Top */} + <div className="gesprek-chat__input-area"> + <div className="gesprek-chat__input-container"> + <textarea + ref={inputRef} + className="gesprek-chat__input" + placeholder={t('placeholder')} + value={input} + onChange={e => setInput(e.target.value)} + onKeyDown={handleKeyDown} + disabled={ragLoading} + rows={1} + /> + <button + className="gesprek-chat__send-btn" + onClick={handleSend} + disabled={!input.trim() || ragLoading} + title={t('send')} + > + {ragLoading ? ( + <Loader2 className="gesprek-chat__send-icon gesprek-chat__send-icon--loading" size={20} /> + ) : ( + <Send className="gesprek-chat__send-icon" size={20} /> + )} + </button> + </div> + + <div className="gesprek-chat__toolbar"> + {/* Model Selector */} + <div className="gesprek-chat__model-selector" ref={modelDropdownRef}> + <button + className="gesprek-chat__model-btn" + onClick={() => setShowModelDropdown(!showModelDropdown)} + type="button" + > + <span className="gesprek-chat__model-label">{t('selectModel')}:</span> + <span className="gesprek-chat__model-name"> + {CLAUDE_MODELS.find(m => m.id === selectedModel)?.name || 'Claude'} + </span> + <ChevronDown + size={14} + className={`gesprek-chat__model-chevron ${showModelDropdown ? 'gesprek-chat__model-chevron--open' : ''}`} + /> + </button> + {showModelDropdown && ( + <div className="gesprek-chat__model-dropdown"> + {CLAUDE_MODELS.map(model => ( + <button + key={model.id} + className={`gesprek-chat__model-option ${selectedModel === model.id ? 'gesprek-chat__model-option--selected' : ''}`} + onClick={() => { + setSelectedModel(model.id); + setShowModelDropdown(false); + }} + > + <span className="gesprek-chat__model-option-name">{model.name}</span> + <span className="gesprek-chat__model-option-desc">{model.description}</span> + </button> + ))} + </div> + )} + </div> + + <div className="gesprek-chat__actions"> + {/* History */} + <div className="gesprek-chat__history-selector" ref={historyDropdownRef}> + <button + className="gesprek-chat__action-btn" + onClick={() => setShowHistoryDropdown(!showHistoryDropdown)} + title={t('history')} + type="button" + > + <History size={16} /> + </button> + {showHistoryDropdown && ( + <div className="gesprek-chat__history-dropdown"> + <div className="gesprek-chat__history-header"> + <span>{t('history')}</span> + {history.length > 0 && ( + <button + className="gesprek-chat__history-clear" + onClick={handleClearHistory} + > + {t('clearHistory')} + </button> + )} + </div> + {history.length === 0 ? ( + <div className="gesprek-chat__history-empty">{t('noHistory')}</div> + ) : ( + <div className="gesprek-chat__history-list"> + {history.slice().reverse().map((item, i) => ( + <button + key={i} + className="gesprek-chat__history-item" + onClick={() => handleHistoryClick(item)} + > + {item.question} + </button> + ))} + </div> + )} + </div> + )} + </div> + + {/* Export */} + <button + className="gesprek-chat__action-btn" + onClick={handleExportConversation} + title={t('exportConversation')} + disabled={messages.length === 0} + type="button" + > + <Download size={16} /> + </button> + + {/* Import */} + <button + className="gesprek-chat__action-btn" + onClick={() => fileInputRef.current?.click()} + title={t('importConversation')} + type="button" + > + <Upload size={16} /> + </button> + + {/* Clear */} + {messages.length > 0 && ( + <button + className="gesprek-chat__action-btn gesprek-chat__action-btn--danger" + onClick={handleClearConversation} + title={t('clearConversation')} + type="button" + > + <Trash2 size={16} /> + </button> + )} + </div> + + <div className="gesprek-chat__powered-by"> + <Sparkles size={12} /> + <span>{t('poweredBy')}</span> + </div> + </div> + </div> + + {/* Messages */} + <div className="gesprek-chat__messages"> + {messages.length === 0 ? ( + <div className="gesprek-chat__welcome"> + <div className="gesprek-chat__welcome-header"> + <Sparkles size={32} className="gesprek-chat__welcome-icon" /> + <h2>{t('welcomeTitle')}</h2> + </div> + <p className="gesprek-chat__welcome-description">{t('welcomeDescription')}</p> + + <div className="gesprek-chat__examples"> + <span className="gesprek-chat__examples-title">{t('exampleQuestions')}</span> + <div className="gesprek-chat__examples-list"> + {EXAMPLE_QUESTIONS[language].map((question, idx) => ( + <button + key={idx} + className="gesprek-chat__example-btn" + onClick={() => handleExampleClick(question)} + > + {question} + </button> + ))} + </div> + </div> + </div> + ) : ( + <> + {messages.map(message => ( + <div + key={message.id} + className={`gesprek-message gesprek-message--${message.role}`} + > + <div className="gesprek-message__content"> + {message.isLoading ? ( + <div className="gesprek-message__loading"> + <Loader2 className="gesprek-message__loading-icon" size={16} /> + <span>{message.content}</span> + </div> + ) : message.error ? ( + <div className="gesprek-message__error"> + <AlertCircle size={16} /> + <span>{message.content}</span> + </div> + ) : ( + <> + <p className="gesprek-message__text">{message.content}</p> + + {/* Query boxes */} + {message.response?.sparqlQuery && ( + <div className="gesprek-message__query-box"> + <div className="gesprek-message__query-header"> + <span>{t('sparqlQuery')}</span> + <button + className="gesprek-message__query-copy" + onClick={() => handleCopyQuery(`sparql-${message.id}`, message.response!.sparqlQuery!)} + > + {copiedId === `sparql-${message.id}` ? ( + <><Check size={14} /> {t('copied')}</> + ) : ( + <><Copy size={14} /> {t('copyQuery')}</> + )} + </button> + </div> + <pre className="gesprek-message__query-code"> + <code>{message.response.sparqlQuery}</code> + </pre> + </div> + )} + + {/* Confidence indicator */} + {message.response && ( + <div className="gesprek-message__meta"> + <span className="gesprek-message__confidence"> + {t('confidence')}: {Math.round(message.response.confidence * 100)}% + </span> + {message.response.context && ( + <span className="gesprek-message__results"> + {message.response.context.totalRetrieved} {t('resultsFound')} + </span> + )} + </div> + )} + </> + )} + </div> + </div> + ))} + <div ref={messagesEndRef} /> + </> + )} + </div> + </div> + + {/* Visualization Panel */} + <div className={`gesprek-viz-panel ${vizExpanded ? 'gesprek-viz-panel--expanded' : ''}`}> + <VisualizationPanel + type={activeVizType} + data={activeVizData ?? undefined} + language={language} + isExpanded={vizExpanded} + onToggleExpand={() => setVizExpanded(!vizExpanded)} + onChangeType={setActiveVizType} + /> + </div> + </div> + </div> + ); +}; + +export default GesprekPage; diff --git a/frontend/src/pages/LinkMLViewerPage.css b/frontend/src/pages/LinkMLViewerPage.css index d83bdf5954..697a03cb29 100644 --- a/frontend/src/pages/LinkMLViewerPage.css +++ b/frontend/src/pages/LinkMLViewerPage.css @@ -318,6 +318,28 @@ border-color: var(--primary-color, #1976d2); } +/* Tab separator for 2D/3D toggle */ +.linkml-viewer-page__tab-separator { + display: flex; + align-items: center; + color: var(--border-color, #e0e0e0); + padding: 0 0.25rem; + font-weight: 300; +} + +/* 3D/2D indicator toggle button */ +.linkml-viewer-page__tab--indicator { + font-size: 0.8125rem; + min-width: auto; + padding: 0.375rem 0.75rem; +} + +/* 3D Custodian Type Indicator container */ +.linkml-viewer__custodian-indicator { + margin-left: 0.5rem; + vertical-align: middle; +} + /* Content Area */ .linkml-viewer-page__content { flex: 1; @@ -476,6 +498,20 @@ color: var(--warning-color, #f57c00); } +/* Custodian Type Badge - shows which GLAMORCUBESFIXPHDNT types apply */ +.linkml-viewer__custodian-badge { + margin-left: 0.5rem; + vertical-align: middle; +} + +/* Item header with badges - flex layout for proper alignment */ +.linkml-viewer__item-name { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; +} + /* URI and Range */ .linkml-viewer__uri, .linkml-viewer__range, diff --git a/frontend/src/pages/LinkMLViewerPage.tsx b/frontend/src/pages/LinkMLViewerPage.tsx index 175a507fcb..2811b6b99b 100644 --- a/frontend/src/pages/LinkMLViewerPage.tsx +++ b/frontend/src/pages/LinkMLViewerPage.tsx @@ -29,6 +29,13 @@ import { import { useLanguage } from '../contexts/LanguageContext'; import { useCollapsibleHeader } from '../hooks/useCollapsibleHeader'; import { ChevronUp, ChevronDown } from 'lucide-react'; +import { CustodianTypeBadge, CustodianTypeIndicator } from '../components/uml/CustodianTypeIndicator'; +import { + getCustodianTypesForClass, + getCustodianTypesForSlot, + getCustodianTypesForEnum, + isUniversalElement, +} from '../lib/schema-custodian-mapping'; import './LinkMLViewerPage.css'; import '../styles/collapsible.css'; @@ -160,6 +167,8 @@ const TEXT = { noMatchingSchemas: { nl: 'Geen overeenkomende schema\'s', en: 'No matching schemas' }, copyToClipboard: { nl: 'Kopieer naar klembord', en: 'Copy to clipboard' }, copied: { nl: 'Gekopieerd!', en: 'Copied!' }, + use3DPolygon: { nl: '3D-polygoon', en: '3D Polygon' }, + use2DBadge: { nl: '2D-badge', en: '2D Badge' }, }; // Dynamically discover schema files from the modules directory @@ -203,6 +212,10 @@ const LinkMLViewerPage: React.FC = () => { // State for copy to clipboard feedback const [copyFeedback, setCopyFeedback] = useState(false); + // State for 3D polygon indicator toggle (future feature) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [use3DIndicator, setUse3DIndicator] = useState(false); + // Collapsible header const mainContentRef = useRef<HTMLElement>(null); const { isCollapsed: isHeaderCollapsed, setIsCollapsed: setIsHeaderCollapsed } = useCollapsibleHeader(mainContentRef); @@ -490,6 +503,8 @@ const LinkMLViewerPage: React.FC = () => { const renderClassDetails = (cls: LinkMLClass) => { const isHighlighted = highlightedClass === cls.name; + const custodianTypes = getCustodianTypesForClass(cls.name); + const isUniversal = isUniversalElement(custodianTypes); return ( <div @@ -500,6 +515,23 @@ const LinkMLViewerPage: React.FC = () => { <h4 className="linkml-viewer__item-name"> {cls.name} {cls.abstract && <span className="linkml-viewer__badge linkml-viewer__badge--abstract">{t('abstract')}</span>} + {!isUniversal && ( + use3DIndicator ? ( + <CustodianTypeIndicator + types={custodianTypes} + size={28} + animate={true} + showTooltip={true} + className="linkml-viewer__custodian-indicator" + /> + ) : ( + <CustodianTypeBadge + types={custodianTypes} + size="small" + className="linkml-viewer__custodian-badge" + /> + ) + )} </h4> {cls.class_uri && ( <div className="linkml-viewer__uri"> @@ -550,6 +582,8 @@ const LinkMLViewerPage: React.FC = () => { const rangeIsEnum = slot.range && isEnumRange(slot.range); const enumKey = slot.range ? `${slot.name}:${slot.range}` : ''; const isExpanded = expandedEnumRanges.has(enumKey); + const custodianTypes = getCustodianTypesForSlot(slot.name); + const isUniversal = isUniversalElement(custodianTypes); return ( <div key={slot.name} className="linkml-viewer__item"> @@ -557,6 +591,23 @@ const LinkMLViewerPage: React.FC = () => { {slot.name} {slot.required && <span className="linkml-viewer__badge linkml-viewer__badge--required">{t('required')}</span>} {slot.multivalued && <span className="linkml-viewer__badge linkml-viewer__badge--multi">{t('multivalued')}</span>} + {!isUniversal && ( + use3DIndicator ? ( + <CustodianTypeIndicator + types={custodianTypes} + size={28} + animate={true} + showTooltip={true} + className="linkml-viewer__custodian-indicator" + /> + ) : ( + <CustodianTypeBadge + types={custodianTypes} + size="small" + className="linkml-viewer__custodian-badge" + /> + ) + )} </h4> {slot.slot_uri && ( <div className="linkml-viewer__uri"> @@ -604,6 +655,8 @@ const LinkMLViewerPage: React.FC = () => { const searchFilter = enumSearchFilters[enumName] || ''; const showAll = enumShowAll[enumName] || false; const displayCount = 20; + const custodianTypes = getCustodianTypesForEnum(enumDef.name); + const isUniversal = isUniversalElement(custodianTypes); // Filter values based on search const filteredValues = searchFilter @@ -622,7 +675,26 @@ const LinkMLViewerPage: React.FC = () => { return ( <div key={enumDef.name} className="linkml-viewer__item"> - <h4 className="linkml-viewer__item-name">{enumDef.name}</h4> + <h4 className="linkml-viewer__item-name"> + {enumDef.name} + {!isUniversal && ( + use3DIndicator ? ( + <CustodianTypeIndicator + types={custodianTypes} + size={28} + animate={true} + showTooltip={true} + className="linkml-viewer__custodian-indicator" + /> + ) : ( + <CustodianTypeBadge + types={custodianTypes} + size="small" + className="linkml-viewer__custodian-badge" + /> + ) + )} + </h4> {enumDef.description && ( <div className="linkml-viewer__description linkml-viewer__markdown"> <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>{transformContent(enumDef.description)}</ReactMarkdown> @@ -1035,6 +1107,14 @@ const LinkMLViewerPage: React.FC = () => { > {t('rawYaml')} </button> + <span className="linkml-viewer-page__tab-separator">|</span> + <button + className={`linkml-viewer-page__tab linkml-viewer-page__tab--indicator ${use3DIndicator ? 'linkml-viewer-page__tab--active' : ''}`} + onClick={() => setUse3DIndicator(!use3DIndicator)} + title={use3DIndicator ? t('use2DBadge') : t('use3DPolygon')} + > + {use3DIndicator ? '🔷 3D' : '🏷️ 2D'} + </button> </div> </header> diff --git a/frontend/src/pages/NDEMapPageMapLibre.tsx b/frontend/src/pages/NDEMapPageMapLibre.tsx index 40dd416adf..ba4e141313 100644 --- a/frontend/src/pages/NDEMapPageMapLibre.tsx +++ b/frontend/src/pages/NDEMapPageMapLibre.tsx @@ -16,6 +16,7 @@ import { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import maplibregl from 'maplibre-gl'; +import type { StyleSpecification, MapLayerMouseEvent, GeoJSONSource } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useLanguage } from '../contexts/LanguageContext'; import { useUIState } from '../contexts/UIStateContext'; @@ -89,7 +90,7 @@ const TYPE_NAMES: Record<string, { nl: string; en: string }> = { }; // Map tile styles for light and dark modes -const getMapStyle = (isDarkMode: boolean): maplibregl.StyleSpecification => { +const getMapStyle = (isDarkMode: boolean): StyleSpecification => { if (isDarkMode) { // CartoDB Dark Matter - dark mode tiles return { @@ -824,7 +825,8 @@ export default function NDEMapPage() { zoom: 7, }); - map.addControl(new maplibregl.NavigationControl(), 'top-right'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map.addControl(new maplibregl.NavigationControl() as any, 'top-right'); map.on('load', () => { mapInstanceRef.current = map; @@ -947,9 +949,9 @@ export default function NDEMapPage() { } // Check if source already exists (with safety check) - let existingSource: maplibregl.GeoJSONSource | undefined; + let existingSource: GeoJSONSource | undefined; try { - existingSource = map.getSource('institutions') as maplibregl.GeoJSONSource | undefined; + existingSource = map.getSource('institutions') as GeoJSONSource | undefined; } catch { console.log('[Markers] Error getting source, map may be destroyed'); return; @@ -1056,12 +1058,12 @@ export default function NDEMapPage() { const map = mapInstanceRef.current; // Click handler using ref to always get current filtered data - const handleClick = (e: maplibregl.MapLayerMouseEvent) => { + const handleClick = (e: MapLayerMouseEvent) => { if (!e.features || e.features.length === 0) return; - const feature = e.features[0]; - const index = feature.properties?.index; - if (index === undefined) return; + const feature = e.features[0] as GeoJSON.Feature; + const index = feature.properties?.index as number | undefined; + if (index === undefined || typeof index !== 'number') return; // Use ref to get current filtered institutions, not stale closure const inst = filteredInstitutionsRef.current[index]; diff --git a/frontend/src/pages/ProjectPlanPage.tsx b/frontend/src/pages/ProjectPlanPage.tsx index 19667e6317..e382cba24c 100644 --- a/frontend/src/pages/ProjectPlanPage.tsx +++ b/frontend/src/pages/ProjectPlanPage.tsx @@ -765,7 +765,7 @@ export default function ProjectPlanPage() { <Paper sx={{ mb: 4 }}> <Tabs value={activeTab} - onChange={(_, newValue) => setActiveTab(newValue)} + onChange={(_: React.SyntheticEvent, newValue: number) => setActiveTab(newValue)} variant="scrollable" scrollButtons="auto" > diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000000..ee1826b69b --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,560 @@ +/// <reference types="vite/client" /> + +// MUI Icons Material module declaration +// The package exports individual icon components but lacks a proper barrel index.d.ts +declare module '@mui/icons-material' { + import type { SvgIconComponent } from '@mui/icons-material/esm'; + export const ExpandMore: SvgIconComponent; + export const CheckCircle: SvgIconComponent; + export const RadioButtonUnchecked: SvgIconComponent; + export const Schedule: SvgIconComponent; + export const Assignment: SvgIconComponent; + export const AccountTree: SvgIconComponent; + export const Timeline: SvgIconComponent; + export const Block: SvgIconComponent; + export const Link: SvgIconComponent; +} + +// For default icon component type +declare module '@mui/icons-material/esm' { + import type { SvgIconProps } from '@mui/material/SvgIcon'; + import type React from 'react'; + export type SvgIconComponent = React.FC<SvgIconProps>; +} + +// Module declarations for packages without type definitions +declare module 'lucide-react' { + import type { FC, SVGAttributes } from 'react'; + + interface LucideIconProps extends SVGAttributes<SVGElement> { + size?: number | string; + color?: string; + strokeWidth?: number | string; + absoluteStrokeWidth?: boolean; + } + + type LucideIcon = FC<LucideIconProps>; + + export const ChevronUp: LucideIcon; + export const ChevronDown: LucideIcon; + export const ChevronRight: LucideIcon; + export const ChevronLeft: LucideIcon; + export const X: LucideIcon; + export const Search: LucideIcon; + export const Filter: LucideIcon; + export const RefreshCw: LucideIcon; + export const Download: LucideIcon; + export const Upload: LucideIcon; + export const Settings: LucideIcon; + export const Info: LucideIcon; + export const AlertTriangle: LucideIcon; + export const AlertCircle: LucideIcon; + export const CheckCircle: LucideIcon; + export const XCircle: LucideIcon; + export const HelpCircle: LucideIcon; + export const Loader2: LucideIcon; + export const Send: LucideIcon; + export const MessageSquare: LucideIcon; + export const Bot: LucideIcon; + export const User: LucideIcon; + export const Copy: LucideIcon; + export const Check: LucideIcon; + export const Play: LucideIcon; + export const Pause: LucideIcon; + export const RotateCcw: LucideIcon; + export const ExternalLink: LucideIcon; + export const Sparkles: LucideIcon; + export const MapPin: LucideIcon; + export const Map: LucideIcon; + export const Layers: LucideIcon; + export const Globe: LucideIcon; + export const Building: LucideIcon; + export const Building2: LucideIcon; + export const Library: LucideIcon; + export const Archive: LucideIcon; + export const Landmark: LucideIcon; + export const Database: LucideIcon; + export const Network: LucideIcon; + export const Share2: LucideIcon; + export const ZoomIn: LucideIcon; + export const ZoomOut: LucideIcon; + export const Maximize: LucideIcon; + export const Maximize2: LucideIcon; + export const Minimize: LucideIcon; + export const Minimize2: LucideIcon; + export const Palette: LucideIcon; + export const Image: LucideIcon; + export const ImageIcon: LucideIcon; + export const FileText: LucideIcon; + export const Eye: LucideIcon; + export const EyeOff: LucideIcon; + export const Sun: LucideIcon; + export const Moon: LucideIcon; + export const Menu: LucideIcon; + export const ArrowRight: LucideIcon; + export const ArrowLeft: LucideIcon; + export const ArrowUp: LucideIcon; + export const ArrowDown: LucideIcon; + export const Plus: LucideIcon; + export const Minus: LucideIcon; + export const Trash2: LucideIcon; + export const Edit: LucideIcon; + export const Save: LucideIcon; + export const Clock: LucideIcon; + export const Calendar: LucideIcon; + export const Star: LucideIcon; + export const Heart: LucideIcon; + export const ThumbsUp: LucideIcon; + export const ThumbsDown: LucideIcon; + export const Flag: LucideIcon; + export const Bookmark: LucideIcon; + export const Tag: LucideIcon; + export const Hash: LucideIcon; + export const AtSign: LucideIcon; + export const Link2: LucideIcon; + export const Unlink: LucideIcon; + export const Lock: LucideIcon; + export const Unlock: LucideIcon; + export const Key: LucideIcon; + export const Shield: LucideIcon; + export const Bell: LucideIcon; + export const BellOff: LucideIcon; + export const Volume2: LucideIcon; + export const VolumeX: LucideIcon; + export const Mic: LucideIcon; + export const MicOff: LucideIcon; + export const Camera: LucideIcon; + export const Video: LucideIcon; + export const Printer: LucideIcon; + export const Mail: LucideIcon; + export const Phone: LucideIcon; + export const Home: LucideIcon; + export const List: LucideIcon; + export const Grid: LucideIcon; + export const LayoutGrid: LucideIcon; + export const LayoutList: LucideIcon; + export const Columns: LucideIcon; + export const Rows: LucideIcon; + export const SlidersHorizontal: LucideIcon; + export const History: LucideIcon; + export const Languages: LucideIcon; + export const BarChart3: LucideIcon; + export const BarChart: LucideIcon; + export const PieChart: LucideIcon; + export const LineChart: LucideIcon; + export const TrendingUp: LucideIcon; + export const TrendingDown: LucideIcon; + export const Activity: LucideIcon; + export const Zap: LucideIcon; + export const Terminal: LucideIcon; + export const Code: LucideIcon; + export const Code2: LucideIcon; + export const FileCode: LucideIcon; + export const Folder: LucideIcon; + export const FolderOpen: LucideIcon; + export const File: LucideIcon; + export const Files: LucideIcon; + export const MoreHorizontal: LucideIcon; + export const MoreVertical: LucideIcon; + export const Grip: LucideIcon; + export const GripVertical: LucideIcon; + export const Move: LucideIcon; + export const Crosshair: LucideIcon; + export const Target: LucideIcon; + export const Compass: LucideIcon; + export const Navigation: LucideIcon; + export const Focus: LucideIcon; + export const Scan: LucideIcon; + export const QrCode: LucideIcon; + export const Table2: LucideIcon; + export const Table: LucideIcon; + export const CreditCard: LucideIcon; + export const LayoutGrid: LucideIcon; + export const IdCard: LucideIcon; +} + +declare module 'mermaid' { + interface MermaidConfig { + startOnLoad?: boolean; + theme?: string; + securityLevel?: string; + fontFamily?: string; + logLevel?: string; + flowchart?: Record<string, unknown>; + sequence?: Record<string, unknown>; + gantt?: Record<string, unknown>; + class?: Record<string, unknown>; + } + interface MermaidAPI { + initialize: (config: MermaidConfig) => void; + render: (id: string, text: string, svgContainingElement?: Element) => Promise<{ svg: string }>; + parse: (text: string) => Promise<boolean>; + } + const mermaid: MermaidAPI; + export default mermaid; +} + +declare module 'maplibre-gl' { + export interface IControl { + onAdd(map: Map): HTMLElement; + onRemove(map: Map): void; + getDefaultPosition?: () => string; + } + + export class Map { + constructor(options: MapOptions); + on(type: string, listener: (e: MapLayerMouseEvent) => void): this; + on(type: string, layerId: string, listener: (e: MapLayerMouseEvent) => void): this; + off(type: string, listener: (e: MapLayerMouseEvent) => void): this; + off(type: string, layerId: string, listener: (e: MapLayerMouseEvent) => void): this; + once(type: string, listener: () => void): this; + remove(): void; + getSource(id: string): GeoJSONSource | undefined; + getLayer(id: string): unknown; + addSource(id: string, source: unknown): this; + addLayer(layer: unknown, before?: string): this; + removeLayer(id: string): this; + removeSource(id: string): this; + moveLayer(id: string, beforeId?: string): this; + setFilter(layerId: string, filter: unknown): this; + setPaintProperty(layerId: string, property: string, value: unknown): this; + setLayoutProperty(layerId: string, property: string, value: unknown): this; + fitBounds(bounds: LngLatBoundsLike, options?: FitBoundsOptions): this; + flyTo(options: FlyToOptions): this; + getCenter(): LngLat; + getZoom(): number; + setCenter(center: LngLatLike): this; + setZoom(zoom: number): this; + resize(): this; + getBounds(): LngLatBounds; + getCanvas(): HTMLCanvasElement; + getContainer(): HTMLElement; + queryRenderedFeatures(point?: PointLike, options?: unknown): MapGeoJSONFeature[]; + project(lngLat: LngLatLike): Point; + unproject(point: PointLike): LngLat; + loaded(): boolean; + isStyleLoaded(): boolean; + isMoving(): boolean; + isZooming(): boolean; + isRotating(): boolean; + triggerRepaint(): void; + easeTo(options: unknown): this; + jumpTo(options: unknown): this; + panTo(lngLat: LngLatLike, options?: unknown): this; + zoomTo(zoom: number, options?: unknown): this; + addControl(control: IControl | NavigationControl | ScaleControl, position?: string): this; + removeControl(control: IControl): this; + setStyle(style: StyleSpecification | string, options?: { diff?: boolean }): this; + getStyle(): StyleSpecification; + } + + export interface MapGeoJSONFeature { + type: 'Feature'; + geometry: GeoJSON.Geometry; + properties: Record<string, unknown>; + id?: string | number; + layer?: unknown; + source?: string; + sourceLayer?: string; + state?: Record<string, unknown>; + } + + export class GeoJSONSource { + setData(data: GeoJSON.GeoJSON): this; + } + + export class Popup { + constructor(options?: PopupOptions); + setLngLat(lngLat: LngLatLike): this; + setHTML(html: string): this; + setText(text: string): this; + addTo(map: Map): this; + remove(): this; + isOpen(): boolean; + } + + export class Marker { + constructor(options?: MarkerOptions); + setLngLat(lngLat: LngLatLike): this; + addTo(map: Map): this; + remove(): this; + getElement(): HTMLElement; + setPopup(popup: Popup): this; + getPopup(): Popup; + } + + export class NavigationControl { + constructor(options?: NavigationControlOptions); + } + + export class ScaleControl { + constructor(options?: ScaleControlOptions); + } + + export class LngLat { + constructor(lng: number, lat: number); + lng: number; + lat: number; + wrap(): LngLat; + toArray(): [number, number]; + toString(): string; + distanceTo(lngLat: LngLat): number; + } + + export class LngLatBounds { + constructor(sw?: LngLatLike, ne?: LngLatLike); + extend(obj: LngLatLike | LngLatBoundsLike): this; + getCenter(): LngLat; + getSouthWest(): LngLat; + getNorthEast(): LngLat; + getNorthWest(): LngLat; + getSouthEast(): LngLat; + getWest(): number; + getSouth(): number; + getEast(): number; + getNorth(): number; + toArray(): [[number, number], [number, number]]; + toString(): string; + isEmpty(): boolean; + contains(lngLat: LngLatLike): boolean; + } + + export class Point { + constructor(x: number, y: number); + x: number; + y: number; + } + + export type LngLatLike = LngLat | [number, number] | { lng: number; lat: number } | { lon: number; lat: number }; + export type LngLatBoundsLike = LngLatBounds | [LngLatLike, LngLatLike] | [number, number, number, number]; + export type PointLike = Point | [number, number]; + + export interface MapOptions { + container: HTMLElement | string; + style: StyleSpecification | string; + center?: LngLatLike; + zoom?: number; + bearing?: number; + pitch?: number; + bounds?: LngLatBoundsLike; + fitBoundsOptions?: FitBoundsOptions; + attributionControl?: boolean; + customAttribution?: string | string[]; + interactive?: boolean; + hash?: boolean | string; + maxBounds?: LngLatBoundsLike; + maxZoom?: number; + minZoom?: number; + maxPitch?: number; + minPitch?: number; + scrollZoom?: boolean; + boxZoom?: boolean; + dragRotate?: boolean; + dragPan?: boolean; + keyboard?: boolean; + doubleClickZoom?: boolean; + touchZoomRotate?: boolean; + touchPitch?: boolean; + cooperativeGestures?: boolean; + trackResize?: boolean; + locale?: Record<string, string>; + fadeDuration?: number; + crossSourceCollisions?: boolean; + collectResourceTiming?: boolean; + clickTolerance?: number; + preserveDrawingBuffer?: boolean; + antialias?: boolean; + refreshExpiredTiles?: boolean; + maxTileCacheSize?: number; + transformRequest?: (url: string, resourceType: string) => unknown; + localIdeographFontFamily?: string; + pitchWithRotate?: boolean; + pixelRatio?: number; + validateStyle?: boolean; + } + + export interface StyleSpecification { + version: number; + name?: string; + metadata?: unknown; + center?: [number, number]; + zoom?: number; + bearing?: number; + pitch?: number; + light?: unknown; + sources: Record<string, unknown>; + sprite?: string; + glyphs?: string; + layers: unknown[]; + terrain?: unknown; + fog?: unknown; + transition?: unknown; + } + + export interface PopupOptions { + closeButton?: boolean; + closeOnClick?: boolean; + closeOnMove?: boolean; + focusAfterOpen?: boolean; + anchor?: string; + offset?: number | PointLike | Record<string, PointLike>; + className?: string; + maxWidth?: string; + } + + export interface MarkerOptions { + element?: HTMLElement; + anchor?: string; + offset?: PointLike; + color?: string; + scale?: number; + draggable?: boolean; + clickTolerance?: number; + rotation?: number; + rotationAlignment?: string; + pitchAlignment?: string; + } + + export interface NavigationControlOptions { + showCompass?: boolean; + showZoom?: boolean; + visualizePitch?: boolean; + } + + export interface ScaleControlOptions { + maxWidth?: number; + unit?: string; + } + + export interface FitBoundsOptions { + padding?: number | { top?: number; bottom?: number; left?: number; right?: number }; + offset?: PointLike; + maxZoom?: number; + maxDuration?: number; + linear?: boolean; + easing?: (t: number) => number; + essential?: boolean; + } + + export interface FlyToOptions { + center?: LngLatLike; + zoom?: number; + bearing?: number; + pitch?: number; + duration?: number; + easing?: (t: number) => number; + offset?: PointLike; + animate?: boolean; + essential?: boolean; + padding?: number | { top?: number; bottom?: number; left?: number; right?: number }; + } + + export interface MapLayerMouseEvent { + type: string; + target: Map; + originalEvent: MouseEvent; + point: Point; + lngLat: LngLat; + preventDefault(): void; + defaultPrevented: boolean; + features?: MapGeoJSONFeature[]; + } +} + +declare module '@mui/material' { + export const Box: React.FC<Record<string, unknown>>; + export const Container: React.FC<Record<string, unknown>>; + export const Typography: React.FC<Record<string, unknown>>; + export const Paper: React.FC<Record<string, unknown>>; + export const Grid: React.FC<Record<string, unknown>>; + export const Card: React.FC<Record<string, unknown>>; + export const CardContent: React.FC<Record<string, unknown>>; + export const CardHeader: React.FC<Record<string, unknown>>; + export const CardActions: React.FC<Record<string, unknown>>; + export const Button: React.FC<Record<string, unknown>>; + export const IconButton: React.FC<Record<string, unknown>>; + export const TextField: React.FC<Record<string, unknown>>; + export const Select: React.FC<Record<string, unknown>>; + export const MenuItem: React.FC<Record<string, unknown>>; + export const FormControl: React.FC<Record<string, unknown>>; + export const FormLabel: React.FC<Record<string, unknown>>; + export const FormHelperText: React.FC<Record<string, unknown>>; + export const InputLabel: React.FC<Record<string, unknown>>; + export const Input: React.FC<Record<string, unknown>>; + export const Checkbox: React.FC<Record<string, unknown>>; + export const Radio: React.FC<Record<string, unknown>>; + export const RadioGroup: React.FC<Record<string, unknown>>; + export const Switch: React.FC<Record<string, unknown>>; + export const Slider: React.FC<Record<string, unknown>>; + export const Tabs: React.FC<Record<string, unknown>>; + export const Tab: React.FC<Record<string, unknown>>; + export const TabPanel: React.FC<Record<string, unknown>>; + export const AppBar: React.FC<Record<string, unknown>>; + export const Toolbar: React.FC<Record<string, unknown>>; + export const Drawer: React.FC<Record<string, unknown>>; + export const Dialog: React.FC<Record<string, unknown>>; + export const DialogTitle: React.FC<Record<string, unknown>>; + export const DialogContent: React.FC<Record<string, unknown>>; + export const DialogActions: React.FC<Record<string, unknown>>; + export const Modal: React.FC<Record<string, unknown>>; + export const Tooltip: React.FC<Record<string, unknown>>; + export const Popover: React.FC<Record<string, unknown>>; + export const Menu: React.FC<Record<string, unknown>>; + export const List: React.FC<Record<string, unknown>>; + export const ListItem: React.FC<Record<string, unknown>>; + export const ListItemText: React.FC<Record<string, unknown>>; + export const ListItemIcon: React.FC<Record<string, unknown>>; + export const ListItemButton: React.FC<Record<string, unknown>>; + export const Divider: React.FC<Record<string, unknown>>; + export const Avatar: React.FC<Record<string, unknown>>; + export const Badge: React.FC<Record<string, unknown>>; + export const Chip: React.FC<Record<string, unknown>>; + export const Alert: React.FC<Record<string, unknown>>; + export const AlertTitle: React.FC<Record<string, unknown>>; + export const Snackbar: React.FC<Record<string, unknown>>; + export const CircularProgress: React.FC<Record<string, unknown>>; + export const LinearProgress: React.FC<Record<string, unknown>>; + export const Skeleton: React.FC<Record<string, unknown>>; + export const Table: React.FC<Record<string, unknown>>; + export const TableBody: React.FC<Record<string, unknown>>; + export const TableCell: React.FC<Record<string, unknown>>; + export const TableContainer: React.FC<Record<string, unknown>>; + export const TableHead: React.FC<Record<string, unknown>>; + export const TableRow: React.FC<Record<string, unknown>>; + export const TablePagination: React.FC<Record<string, unknown>>; + export const TableSortLabel: React.FC<Record<string, unknown>>; + export const Accordion: React.FC<Record<string, unknown>>; + export const AccordionSummary: React.FC<Record<string, unknown>>; + export const AccordionDetails: React.FC<Record<string, unknown>>; + export const Breadcrumbs: React.FC<Record<string, unknown>>; + export const Link: React.FC<Record<string, unknown>>; + export const Pagination: React.FC<Record<string, unknown>>; + export const Rating: React.FC<Record<string, unknown>>; + export const Stepper: React.FC<Record<string, unknown>>; + export const Step: React.FC<Record<string, unknown>>; + export const StepLabel: React.FC<Record<string, unknown>>; + export const StepContent: React.FC<Record<string, unknown>>; + export const SpeedDial: React.FC<Record<string, unknown>>; + export const SpeedDialAction: React.FC<Record<string, unknown>>; + export const SpeedDialIcon: React.FC<Record<string, unknown>>; + export const ToggleButton: React.FC<Record<string, unknown>>; + export const ToggleButtonGroup: React.FC<Record<string, unknown>>; + export const Fab: React.FC<Record<string, unknown>>; + export const FormGroup: React.FC<Record<string, unknown>>; + export const FormControlLabel: React.FC<Record<string, unknown>>; + export const InputAdornment: React.FC<Record<string, unknown>>; + export const Autocomplete: React.FC<Record<string, unknown>>; + export const Stack: React.FC<Record<string, unknown>>; + export const Collapse: React.FC<Record<string, unknown>>; + export const Fade: React.FC<Record<string, unknown>>; + export const Grow: React.FC<Record<string, unknown>>; + export const Slide: React.FC<Record<string, unknown>>; + export const Zoom: React.FC<Record<string, unknown>>; + export const useTheme: () => unknown; + export const useMediaQuery: (query: string) => boolean; + export const createTheme: (options: unknown) => unknown; + export const ThemeProvider: React.FC<Record<string, unknown>>; + export const CssBaseline: React.FC; + export const GlobalStyles: React.FC<Record<string, unknown>>; + export const styled: (component: unknown, options?: unknown) => unknown; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index dbdf00d1e8..ce1b7152e2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,12 +4,30 @@ import path from 'path' // https://vite.dev/config/ export default defineConfig({ + logLevel: 'info', plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, + build: { + // Increase chunk size warning limit (mermaid is large) + chunkSizeWarningLimit: 2000, + rollupOptions: { + output: { + // Manual chunks to separate large dependencies + manualChunks: { + maplibre: ['maplibre-gl'], + }, + }, + }, + }, + optimizeDeps: { + include: ['maplibre-gl'], + // Exclude mermaid from pre-bundling - it's dynamically imported + exclude: ['mermaid'], + }, server: { port: 5173, proxy: { diff --git a/schemas/20251121/linkml/01_custodian_name_modular.yaml b/schemas/20251121/linkml/01_custodian_name_modular.yaml index 3f7e76df4b..48d3ebc1ec 100644 --- a/schemas/20251121/linkml/01_custodian_name_modular.yaml +++ b/schemas/20251121/linkml/01_custodian_name_modular.yaml @@ -185,7 +185,7 @@ imports: - modules/enums/ReconstructionActivityTypeEnum - modules/enums/SourceDocumentTypeEnum # StaffRoleTypeEnum REMOVED - replaced by StaffRole class hierarchy - # See: .opencode/ENUM_TO_CLASS_PRINCIPLE.md for rationale + # See: rules/ENUM_TO_CLASS_PRINCIPLE.md for rationale - modules/enums/CallForApplicationStatusEnum - modules/enums/FundingRequirementTypeEnum @@ -242,7 +242,7 @@ imports: - modules/classes/PersonObservation # Staff role class hierarchy (replaces StaffRoleTypeEnum - Single Source of Truth) - # See: .opencode/ENUM_TO_CLASS_PRINCIPLE.md + # See: rules/ENUM_TO_CLASS_PRINCIPLE.md - modules/classes/StaffRole - modules/classes/StaffRoles diff --git a/schemas/20251121/linkml/modules/classes/Conservatoria.yaml b/schemas/20251121/linkml/modules/classes/Conservatoria.yaml index 1a8dd49fa5..9a97640a06 100644 --- a/schemas/20251121/linkml/modules/classes/Conservatoria.yaml +++ b/schemas/20251121/linkml/modules/classes/Conservatoria.yaml @@ -2,6 +2,9 @@ id: https://nde.nl/ontology/hc/class/Conservatoria name: Conservatoria title: Conservatória Type (Lusophone) +prefixes: + linkml: https://w3id.org/linkml/ + imports: - linkml:types - ./ArchiveOrganizationType @@ -16,7 +19,8 @@ classes: **Wikidata**: Q9854379 - **Geographic Restriction**: Portugal, Brazil, and other Lusophone countries + **Geographic Restriction**: Lusophone countries (PT, BR, AO, MZ, CV, GW, ST, TL) + This constraint is enforced via LinkML `rules` with `postconditions`. **CUSTODIAN-ONLY**: This type does NOT have a corresponding rico:RecordSetType class. Conservatórias are administrative offices with registration functions, @@ -59,6 +63,7 @@ classes: **Multilingual Labels**: - pt: Conservatória + - pt-BR: Cartório de Registro slot_usage: primary_type: @@ -70,10 +75,49 @@ classes: wikidata_entity: description: | - Should be Q9854379 for Conservatórias. + MUST be Q9854379 for Conservatórias. Lusophone civil/property registration offices. pattern: "^Q[0-9]+$" equals_string: "Q9854379" + + applicable_countries: + description: | + **Geographic Restriction**: Lusophone countries only. + + Conservatórias exist in Portuguese-speaking countries: + - PT (Portugal) - Conservatórias do Registo + - BR (Brazil) - Cartórios de Registro + - AO (Angola) - Conservatórias + - MZ (Mozambique) - Conservatórias + - CV (Cape Verde) - Conservatórias + - GW (Guinea-Bissau) - Conservatórias + - ST (São Tomé and Príncipe) - Conservatórias + - TL (Timor-Leste) - Conservatórias (Portuguese legal heritage) + + The `rules` section below enforces this constraint during validation. + multivalued: true + required: true + minimum_cardinality: 1 + + # LinkML rules for geographic constraint validation + rules: + - description: >- + Conservatoria MUST have applicable_countries containing at least one + Lusophone country (PT, BR, AO, MZ, CV, GW, ST, TL). + This is a mandatory geographic restriction for Portuguese-speaking + civil registry and notarial archive offices. + postconditions: + slot_conditions: + applicable_countries: + any_of: + - equals_string: "PT" + - equals_string: "BR" + - equals_string: "AO" + - equals_string: "MZ" + - equals_string: "CV" + - equals_string: "GW" + - equals_string: "ST" + - equals_string: "TL" exact_mappings: - skos:Concept @@ -82,8 +126,10 @@ classes: - rico:CorporateBody comments: + - "Conservatória (pt)" + - "Cartório de Registro (pt-BR)" - "CUSTODIAN-ONLY type: No corresponding rico:RecordSetType class" - - "Geographic restriction: Lusophone countries (Portugal, Brazil, etc.)" + - "Geographic restriction enforced via LinkML rules: Lusophone countries only" - "Government registration office, not traditional archive" - "Essential for genealogical and legal research" diff --git a/schemas/20251121/linkml/modules/classes/CountyRecordOffice.yaml b/schemas/20251121/linkml/modules/classes/CountyRecordOffice.yaml index 0780574718..6a767928ca 100644 --- a/schemas/20251121/linkml/modules/classes/CountyRecordOffice.yaml +++ b/schemas/20251121/linkml/modules/classes/CountyRecordOffice.yaml @@ -2,21 +2,27 @@ id: https://nde.nl/ontology/hc/class/CountyRecordOffice name: CountyRecordOffice title: County Record Office Type +prefixes: + linkml: https://w3id.org/linkml/ + org: http://www.w3.org/ns/org# + imports: - linkml:types - ./ArchiveOrganizationType + - ./OrganizationBranch classes: CountyRecordOffice: is_a: ArchiveOrganizationType class_uri: skos:Concept description: | - Local authority repository in the United Kingdom and similar jurisdictions, - preserving historical records of the county and its communities. + Local authority repository in the United Kingdom, preserving historical + records of the county and its communities. **Wikidata**: Q5177943 - **Geographic Context**: Primarily United Kingdom + **Geographic Restriction**: United Kingdom (GB) only. + This constraint is enforced via LinkML `rules` with `postconditions`. **CUSTODIAN-ONLY**: This type does NOT have a corresponding rico:RecordSetType class. County Record Offices are institutional types, not collection @@ -40,16 +46,25 @@ classes: - Often designated as place of deposit for public records - Increasingly rebranded as "Archives and Local Studies" + In Scotland: + - Similar functions performed by local authority archives + - National Records of Scotland at national level + + In Northern Ireland: + - Public Record Office of Northern Ireland (PRONI) + - Local council archives + **Related Types**: - LocalGovernmentArchive (Q118281267) - Local authority records - MunicipalArchive (Q604177) - City/town archives - LocalHistoryArchive (Q12324798) - Local history focus **Notable Examples**: - - The National Archives (Kew) - National level - London Metropolitan Archives - Oxfordshire History Centre - Lancashire Archives + - West Yorkshire Archive Service + - Surrey History Centre **Ontological Alignment**: - **SKOS**: skos:Concept with skos:broader Q166118 (archive) @@ -57,6 +72,8 @@ classes: - **RiC-O**: rico:CorporateBody (as agent) **Multilingual Labels**: + - en: County Record Office + - en-GB: County Record Office - it: archivio pubblico territoriale slot_usage: @@ -67,7 +84,7 @@ classes: wikidata_entity: description: | - Should be Q5177943 for county record offices. + MUST be Q5177943 for county record offices. UK local authority archive type. pattern: "^Q[0-9]+$" equals_string: "Q5177943" @@ -76,6 +93,66 @@ classes: description: | Typically 'county' or 'local' for this archive type. Corresponds to UK county administrative level. + + is_branch_of_authority: + description: | + **Organizational Relationship**: County Record Offices may be branches + of larger local authority structures. + + **Common Parent Organizations**: + - County Councils (e.g., Oxfordshire County Council) + - Unitary Authorities (e.g., Bristol City Council) + - Combined Authorities (e.g., Greater Manchester) + - Joint Archive Services (e.g., East Sussex / Brighton & Hove) + + **Legal Context**: + County Record Offices are typically: + - Designated "place of deposit" under Public Records Act 1958 + - Part of local authority heritage/cultural services + - May share governance with local studies libraries + + **Use org:unitOf pattern** from OrganizationBranch to link to parent + authority when modeled as formal organizational unit. + + **Examples**: + - Oxfordshire History Centre → part of Oxfordshire County Council + - London Metropolitan Archives → part of City of London Corporation + - West Yorkshire Archive Service → joint service of five councils + range: uriorcurie + multivalued: false + required: false + examples: + - value: "https://nde.nl/ontology/hc/uk/oxfordshire-county-council" + description: "Parent local authority" + + applicable_countries: + description: | + **Geographic Restriction**: United Kingdom (GB) only. + + County Record Offices are a UK-specific institution type within + the local authority structure of England, Wales, Scotland, and + Northern Ireland. + + Note: Uses ISO 3166-1 alpha-2 code "GB" for United Kingdom + (not "UK" which is not a valid ISO code). + + The `rules` section below enforces this constraint during validation. + ifabsent: "string(GB)" + required: true + minimum_cardinality: 1 + maximum_cardinality: 1 + + # LinkML rules for geographic constraint validation + rules: + - description: >- + CountyRecordOffice MUST have applicable_countries containing "GB" + (United Kingdom). This is a mandatory geographic restriction for + UK county record offices and local authority archives. + postconditions: + slot_conditions: + applicable_countries: + any_of: + - equals_string: "GB" exact_mappings: - skos:Concept @@ -84,7 +161,9 @@ classes: - rico:CorporateBody comments: + - "County Record Office (en-GB)" - "CUSTODIAN-ONLY type: No corresponding rico:RecordSetType class" + - "Geographic restriction enforced via LinkML rules: United Kingdom (GB) only" - "UK local authority archive institution type" - "Often designated place of deposit for public records" - "Key resource for local and family history research" @@ -93,3 +172,12 @@ classes: - LocalGovernmentArchive - MunicipalArchive - LocalHistoryArchive + - OrganizationBranch + +slots: + is_branch_of_authority: + slot_uri: org:unitOf + description: | + Parent local authority or governing body for this County Record Office. + Uses W3C Org ontology org:unitOf relationship. + range: uriorcurie diff --git a/schemas/20251121/linkml/modules/classes/CurrentArchive.yaml b/schemas/20251121/linkml/modules/classes/CurrentArchive.yaml index ddd9622993..9c719757ba 100644 --- a/schemas/20251121/linkml/modules/classes/CurrentArchive.yaml +++ b/schemas/20251121/linkml/modules/classes/CurrentArchive.yaml @@ -22,6 +22,7 @@ imports: - linkml:types - ./ArchiveOrganizationType - ./CustodianAdministration + - ./CustodianArchive classes: CurrentArchive: @@ -63,6 +64,24 @@ classes: - HistoricalArchive (Q3621673) - non-current permanent records - RecordsCenter - semi-current storage facility + **RELATIONSHIP TO CustodianArchive**: + + CurrentArchive (this class) is a TYPE classification (skos:Concept) for + archives managing records in the active/current phase of the lifecycle. + + CustodianArchive is an INSTANCE class (rico:RecordSet) representing the + actual operational archives of a heritage custodian awaiting processing. + + **Semantic Relationship**: + - CurrentArchive is a HYPERNYM (broader type) for the concept of active records + - CustodianArchive records MAY be typed as CurrentArchive when in active use + - When CustodianArchive.processing_status = "UNPROCESSED", records may still + be in the current/active phase conceptually + + **SKOS Alignment**: + - skos:broader: CurrentArchive → DepositArchive (lifecycle progression) + - skos:narrower: CurrentArchive ← specific current archive types + **ONTOLOGICAL ALIGNMENT**: - **SKOS**: skos:Concept (type classification) - **RiC-O**: rico:RecordSet for active record groups @@ -74,6 +93,7 @@ classes: - retention_schedule - creating_organization - transfer_policy + - has_narrower_instance slot_usage: wikidata_entity: @@ -101,6 +121,25 @@ classes: Policy for transferring records to intermediate or permanent archives. Describes triggers, timelines, and procedures for transfer. range: string + + has_narrower_instance: + slot_uri: skos:narrowerTransitive + description: | + Links this archive TYPE to specific CustodianArchive INSTANCES + that are classified under this lifecycle phase. + + **SKOS**: skos:narrowerTransitive for type-instance relationship. + + **Usage**: + When a CustodianArchive contains records in the "current/active" phase, + it can be linked from CurrentArchive via this property. + + **Example**: + - CurrentArchive (type) → has_narrower_instance → + CustodianArchive "Director's Active Files 2020-2024" (instance) + range: CustodianArchive + multivalued: true + required: false exact_mappings: - wikidata:Q3621648 @@ -145,3 +184,11 @@ slots: transfer_policy: description: Policy for transferring to permanent archive range: string + + has_narrower_instance: + slot_uri: skos:narrowerTransitive + description: | + Links archive TYPE to specific CustodianArchive INSTANCES. + SKOS narrowerTransitive for type-to-instance relationship. + range: CustodianArchive + multivalued: true diff --git a/schemas/20251121/linkml/modules/classes/CustodianArchive.yaml b/schemas/20251121/linkml/modules/classes/CustodianArchive.yaml index 97ed886f6f..dc5d1cdb69 100644 --- a/schemas/20251121/linkml/modules/classes/CustodianArchive.yaml +++ b/schemas/20251121/linkml/modules/classes/CustodianArchive.yaml @@ -20,6 +20,7 @@ imports: - ../slots/access_restrictions - ../slots/storage_location - ./ReconstructedEntity + - ./CurrentArchive prefixes: linkml: https://w3id.org/linkml/ @@ -31,6 +32,8 @@ prefixes: time: http://www.w3.org/2006/time# org: http://www.w3.org/ns/org# premis: http://www.loc.gov/premis/rdf/v3/ + skos: http://www.w3.org/2004/02/skos/core# + wikidata: http://www.wikidata.org/entity/ classes: CustodianArchive: @@ -122,6 +125,18 @@ classes: - **Storage**: Physical location of unprocessed archives - **OrganizationalStructure**: Unit responsible for processing + **RELATIONSHIP TO LIFECYCLE TYPE CLASSES**: + + CustodianArchive (this class) is an INSTANCE class representing actual + operational archives. It can be TYPED using lifecycle phase classifications: + + - **CurrentArchive** (Q3621648): Active records in daily use + - skos:broaderTransitive links CustodianArchive → CurrentArchive type + - **DepositArchive** (Q244904): Intermediate/semi-current records + - **HistoricalArchive** (Q3621673): Permanent archival records + + Use `lifecycle_phase_type` slot to classify by lifecycle position. + exact_mappings: - rico:RecordSet @@ -162,6 +177,7 @@ classes: - was_generated_by - valid_from - valid_to + - lifecycle_phase_type slot_usage: id: @@ -591,6 +607,33 @@ classes: required: false description: | End of validity period (typically = transfer_to_collection_date). + + lifecycle_phase_type: + slot_uri: skos:broaderTransitive + range: uriorcurie + required: false + description: | + Links this CustodianArchive INSTANCE to its lifecycle phase TYPE. + + **SKOS**: skos:broaderTransitive for instance-to-type relationship. + + **Archive Lifecycle Types (Wikidata)**: + - Q3621648 (CurrentArchive) - Active records phase + - Q244904 (DepositArchive) - Intermediate/semi-current phase + - Q3621673 (HistoricalArchive) - Archival/permanent phase + + **Usage**: + Classify this operational archive by its position in the records lifecycle. + Most CustodianArchive records are in the intermediate phase (awaiting processing). + + **Example**: + - CustodianArchive "Ministry Records 2010-2020" → lifecycle_phase_type → + DepositArchive (Q244904) - semi-current, awaiting processing + examples: + - value: "wikidata:Q244904" + description: "Deposit archive / semi-current records" + - value: "wikidata:Q3621648" + description: "Current archive / active records" comments: - "Represents operational archives BEFORE integration into CustodianCollection" @@ -719,3 +762,12 @@ slots: arrangement_notes: description: Notes from arrangement process range: string + + lifecycle_phase_type: + slot_uri: skos:broaderTransitive + description: | + Links CustodianArchive INSTANCE to lifecycle phase TYPE. + SKOS broaderTransitive for instance-to-type relationship. + Values: CurrentArchive (Q3621648), DepositArchive (Q244904), + HistoricalArchive (Q3621673). + range: uriorcurie diff --git a/schemas/20251121/linkml/modules/classes/CustodianName.yaml b/schemas/20251121/linkml/modules/classes/CustodianName.yaml index 71d17e2c08..2019410f26 100644 --- a/schemas/20251121/linkml/modules/classes/CustodianName.yaml +++ b/schemas/20251121/linkml/modules/classes/CustodianName.yaml @@ -61,7 +61,7 @@ classes: - Portuguese: Fundação, Associação, Ltda., S.A. - Italian: Fondazione, Associazione, S.p.A., S.r.l. - See: .opencode/LEGAL_FORM_FILTERING_RULE.md for comprehensive global list + See: rules/LEGAL_FORM_FILTERING_RULE.md for comprehensive global list =========================================================================== MANDATORY RULE: Special Characters MUST Be Excluded from Abbreviations @@ -112,7 +112,7 @@ classes: - "Heritage@Digital" → "HD" (not "H@D") - "Archives (Historical)" → "AH" (not "A(H)") - See: .opencode/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation + See: rules/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation =========================================================================== MANDATORY RULE: Diacritics MUST Be Normalized to ASCII in Abbreviations @@ -152,7 +152,7 @@ classes: ascii_text = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn') ``` - See: .opencode/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation + See: rules/ABBREVIATION_SPECIAL_CHAR_RULE.md for complete documentation Can be generated by: 1. ReconstructionActivity (formal entity resolution) - was_generated_by link diff --git a/schemas/20251121/linkml/modules/classes/WebClaim.yaml b/schemas/20251121/linkml/modules/classes/WebClaim.yaml index 7ab5f61a10..9458360844 100644 --- a/schemas/20251121/linkml/modules/classes/WebClaim.yaml +++ b/schemas/20251121/linkml/modules/classes/WebClaim.yaml @@ -470,7 +470,7 @@ classes: - "Follows 4-stage GLAM-NER pipeline: recognition → layout → resolution → linking" see_also: - - ".opencode/WEB_OBSERVATION_PROVENANCE_RULES.md" + - "rules/WEB_OBSERVATION_PROVENANCE_RULES.md" - "scripts/fetch_website_playwright.py" - "scripts/add_xpath_provenance.py" - "docs/convention/schema/20251202/entity_annotation_rules_v1.6.0_unified.yaml" diff --git a/schemas/20251121/linkml/rules/ABBREVIATION_RULES.md b/schemas/20251121/linkml/rules/ABBREVIATION_RULES.md new file mode 100644 index 0000000000..8bb428ee75 --- /dev/null +++ b/schemas/20251121/linkml/rules/ABBREVIATION_RULES.md @@ -0,0 +1,303 @@ +# Abbreviation Character Filtering Rules + +**Rule ID**: ABBREV-CHAR-FILTER +**Status**: MANDATORY +**Applies To**: GHCID abbreviation component generation +**Created**: 2025-12-07 +**Updated**: 2025-12-08 (added diacritics rule) + +--- + +## Summary + +**When generating abbreviations for GHCID, ONLY ASCII uppercase letters (A-Z) are permitted. Both special characters AND diacritics MUST be removed/normalized.** + +This is a **MANDATORY** rule. Abbreviations containing special characters or diacritics are INVALID and must be regenerated. + +### Two Mandatory Sub-Rules: + +1. **ABBREV-SPECIAL-CHAR**: Remove all special characters and symbols +2. **ABBREV-DIACRITICS**: Normalize all diacritics to ASCII equivalents + +--- + +## Rule 1: Diacritics MUST Be Normalized to ASCII (ABBREV-DIACRITICS) + +**Diacritics (accented characters) MUST be normalized to their ASCII base letter equivalents.** + +### Example (Real Case) + +``` +❌ WRONG: CZ-VY-TEL-L-VHSPAOČRZS (contains Č) +✅ CORRECT: CZ-VY-TEL-L-VHSPAOCRZS (ASCII only) +``` + +### Diacritics Normalization Table + +| Diacritic | ASCII | Example | +|-----------|-------|---------| +| Á, À, Â, Ã, Ä, Å, Ā | A | "Ålborg" → A | +| Č, Ć, Ç | C | "Český" → C | +| Ď | D | "Ďáblice" → D | +| É, È, Ê, Ë, Ě, Ē | E | "Éire" → E | +| Í, Ì, Î, Ï, Ī | I | "Ísland" → I | +| Ñ, Ń, Ň | N | "España" → N | +| Ó, Ò, Ô, Õ, Ö, Ø, Ō | O | "Österreich" → O | +| Ř | R | "Říčany" → R | +| Š, Ś, Ş | S | "Šumperk" → S | +| Ť | T | "Ťažký" → T | +| Ú, Ù, Û, Ü, Ů, Ū | U | "Ústí" → U | +| Ý, Ÿ | Y | "Ýmir" → Y | +| Ž, Ź, Ż | Z | "Žilina" → Z | +| Ł | L | "Łódź" → L | +| Æ | AE | "Ærø" → AE | +| Œ | OE | "Œuvre" → OE | +| ß | SS | "Straße" → SS | + +### Implementation + +```python +import unicodedata + +def normalize_diacritics(text: str) -> str: + """ + Normalize diacritics to ASCII equivalents. + + Examples: + "Č" → "C" + "Ř" → "R" + "Ö" → "O" + "ñ" → "n" + """ + # NFD decomposition separates base characters from combining marks + normalized = unicodedata.normalize('NFD', text) + # Remove combining marks (category 'Mn' = Mark, Nonspacing) + ascii_text = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn') + return ascii_text + +# Example +normalize_diacritics("VHSPAOČRZS") # Returns "VHSPAOCRZS" +``` + +### Languages Commonly Affected + +| Language | Common Diacritics | Example Institution | +|----------|-------------------|---------------------| +| **Czech** | Č, Ř, Š, Ž, Ě, Ů | Vlastivědné muzeum → VM (not VM with háček) | +| **Polish** | Ł, Ń, Ó, Ś, Ź, Ż, Ą, Ę | Biblioteka Łódzka → BL | +| **German** | Ä, Ö, Ü, ß | Österreichische Nationalbibliothek → ON | +| **French** | É, È, Ê, Ç, Ô | Bibliothèque nationale → BN | +| **Spanish** | Ñ, Á, É, Í, Ó, Ú | Museo Nacional → MN | +| **Portuguese** | Ã, Õ, Ç, Á, É | Biblioteca Nacional → BN | +| **Nordic** | Å, Ä, Ö, Ø, Æ | Nationalmuseet → N | +| **Turkish** | Ç, Ğ, İ, Ö, Ş, Ü | İstanbul Üniversitesi → IU | +| **Hungarian** | Á, É, Í, Ó, Ö, Ő, Ú, Ü, Ű | Országos Levéltár → OL | +| **Romanian** | Ă, Â, Î, Ș, Ț | Biblioteca Națională → BN | + +--- + +## Rule 2: Special Characters MUST Be Removed (ABBREV-SPECIAL-CHAR) + +--- + +## Rationale + +### 1. URL/URI Safety +Special characters require percent-encoding in URIs. For example: +- `&` becomes `%26` +- `+` becomes `%2B` + +This makes identifiers harder to share, copy, and verify. + +### 2. Filename Safety +Many special characters are invalid in filenames across operating systems: +- Windows: `\ / : * ? " < > |` +- macOS/Linux: `/` and null bytes + +Files like `SX-XX-PHI-O-DR&IMSM.yaml` may cause issues on some systems. + +### 3. Parsing Consistency +Special characters can conflict with delimiters in data pipelines: +- `&` is used in query strings +- `:` is used in YAML, JSON +- `/` is a path separator +- `|` is a common CSV delimiter alternative + +### 4. Cross-System Compatibility +Identifiers should work across all systems: +- Databases (SQL, TypeDB, Neo4j) +- RDF/SPARQL endpoints +- REST APIs +- Command-line tools +- Spreadsheets + +### 5. Human Readability +Clean identifiers are easier to: +- Communicate verbally +- Type correctly +- Proofread +- Remember + +--- + +## Characters to Remove + +The following characters MUST be completely removed (not replaced) when generating abbreviations: + +| Character | Name | Example Issue | +|-----------|------|---------------| +| `&` | Ampersand | "R&A" in URLs, HTML entities | +| `/` | Slash | Path separator confusion | +| `\` | Backslash | Escape sequence issues | +| `+` | Plus | URL encoding (`+` = space) | +| `@` | At sign | Email/handle confusion | +| `#` | Hash/Pound | Fragment identifier in URLs | +| `%` | Percent | URL encoding prefix | +| `$` | Dollar | Variable prefix in shells | +| `*` | Asterisk | Glob/wildcard character | +| `(` `)` | Parentheses | Grouping in regex, code | +| `[` `]` | Square brackets | Array notation | +| `{` `}` | Curly braces | Object notation | +| `\|` | Pipe | Command chaining, OR operator | +| `:` | Colon | YAML key-value, namespace separator | +| `;` | Semicolon | Statement terminator | +| `"` `'` `` ` `` | Quotes | String delimiters | +| `,` | Comma | List separator | +| `.` | Period | File extension, namespace | +| `-` | Hyphen | Already used as GHCID component separator | +| `_` | Underscore | Reserved for name suffix in collisions | +| `=` | Equals | Assignment operator | +| `?` | Question mark | Query string indicator | +| `!` | Exclamation | Negation, shell history | +| `~` | Tilde | Home directory, bitwise NOT | +| `^` | Caret | Regex anchor, power operator | +| `<` `>` | Angle brackets | HTML tags, redirects | + +--- + +## Implementation + +### Algorithm + +When extracting abbreviation from institution name: + +```python +import re +import unicodedata + +def extract_abbreviation_from_name(name: str, skip_words: set) -> str: + """ + Extract abbreviation from institution name. + + Args: + name: Full institution name (emic) + skip_words: Set of prepositions/articles to skip + + Returns: + Uppercase abbreviation with only A-Z characters + """ + # Step 1: Normalize unicode (remove diacritics) + normalized = unicodedata.normalize('NFD', name) + ascii_name = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn') + + # Step 2: Replace special characters with spaces (to split words) + # This handles cases like "Records&Information" -> "Records Information" + clean_name = re.sub(r'[^a-zA-Z\s]', ' ', ascii_name) + + # Step 3: Split into words + words = clean_name.split() + + # Step 4: Filter out skip words (prepositions, articles) + significant_words = [w for w in words if w.lower() not in skip_words] + + # Step 5: Take first letter of each significant word + abbreviation = ''.join(w[0].upper() for w in significant_words if w) + + # Step 6: Limit to 10 characters + return abbreviation[:10] +``` + +### Handling Special Cases + +**Case 1: "Records & Information Management"** +1. Input: `"Records & Information Management"` +2. After special char removal: `"Records Information Management"` +3. After split: `["Records", "Information", "Management"]` +4. Abbreviation: `RIM` + +**Case 2: "Art/Design Museum"** +1. Input: `"Art/Design Museum"` +2. After special char removal: `"Art Design Museum"` +3. After split: `["Art", "Design", "Museum"]` +4. Abbreviation: `ADM` + +**Case 3: "Culture+"** +1. Input: `"Culture+"` +2. After special char removal: `"Culture"` +3. After split: `["Culture"]` +4. Abbreviation: `C` + +--- + +## Examples + +| Institution Name | Correct | Incorrect | +|------------------|---------|-----------| +| Department of Records & Information Management | DRIM | DR&IM | +| Art + Culture Center | ACC | A+CC | +| Museum/Gallery Amsterdam | MGA | M/GA | +| Heritage@Digital | HD | H@D | +| Archives (Historical) | AH | A(H) | +| Research & Development Institute | RDI | R&DI | +| Sint Maarten Records & Information | SMRI | SMR&I | + +--- + +## Validation + +### Check for Invalid Abbreviations + +```bash +# Find GHCID files with special characters in abbreviation +find data/custodian -name "*.yaml" | xargs grep -l '[&+@#%$*|:;?!=~^<>]' | head -20 + +# Specifically check for & in filenames +find data/custodian -name "*&*.yaml" +``` + +### Programmatic Validation + +```python +import re + +def validate_abbreviation(abbrev: str) -> bool: + """ + Validate that abbreviation contains only A-Z. + + Returns True if valid, False if contains special characters. + """ + return bool(re.match(r'^[A-Z]+$', abbrev)) + +# Examples +validate_abbreviation("DRIMSM") # True - valid +validate_abbreviation("DR&IMSM") # False - contains & +validate_abbreviation("A+CC") # False - contains + +``` + +--- + +## Related Documentation + +- `AGENTS.md` - Section "INSTITUTION ABBREVIATION: EMIC NAME FIRST-LETTER PROTOCOL" +- `schemas/20251121/linkml/modules/classes/CustodianName.yaml` - Schema description +- `rules/LEGAL_FORM_FILTER.md` - Related filtering rule for legal forms +- `docs/PERSISTENT_IDENTIFIERS.md` - GHCID specification + +--- + +## Changelog + +| Date | Change | +|------|--------| +| 2025-12-07 | Initial rule created after discovery of `&` in GHCID | +| 2025-12-08 | Added diacritics normalization rule | diff --git a/schemas/20251121/linkml/rules/ENUM_TO_CLASS.md b/schemas/20251121/linkml/rules/ENUM_TO_CLASS.md new file mode 100644 index 0000000000..c502d45773 --- /dev/null +++ b/schemas/20251121/linkml/rules/ENUM_TO_CLASS.md @@ -0,0 +1,237 @@ +# Enum-to-Class Principle: Single Source of Truth + +**Rule ID**: ENUM-TO-CLASS +**Status**: ACTIVE +**Applies To**: Schema evolution decisions +**Version**: 1.0 +**Last Updated**: 2025-12-06 + +--- + +## Core Principle + +**Enums are TEMPORARY scaffolding. Once an enum is promoted to a class hierarchy, the enum MUST be deleted to maintain a Single Source of Truth.** + +--- + +## Rationale + +### The Problem: Dual Representation + +When both an enum AND a class hierarchy exist for the same concept: +- **Data sync issues**: Enum values and class names can drift apart +- **Maintenance burden**: Changes must be made in two places +- **Developer confusion**: Which one should I use? +- **Validation conflicts**: Enum constraints vs class ranges may diverge + +### The Solution: Single Source of Truth + +- **Enums**: Use for simple, fixed value constraints (e.g., `DataTierEnum: TIER_1, TIER_2, TIER_3, TIER_4`) +- **Classes**: Use when the concept needs properties, relationships, or rich documentation +- **NEVER BOTH**: Once promoted to classes, DELETE the enum + +--- + +## When to Promote Enum to Classes + +**Promote when the concept needs**: + +| Need | Enum Can Do? | Class Required? | +|------|-------------|-----------------| +| Fixed value constraint | Yes | Yes | +| Properties (e.g., `role_category`, `typical_domains`) | No | Yes | +| Rich description per value | Limited | Yes | +| Relationships to other entities | No | Yes | +| Inheritance hierarchy | No | Yes | +| Independent identity (URI) | Limited | Yes | +| Ontology class mapping (`class_uri`) | Via `meaning` | Native | + +**Rule of thumb**: If you're adding detailed documentation to each enum value, or want to attach properties, it's time to promote to classes. + +--- + +## Promotion Workflow + +### Step 1: Create Class Hierarchy + +```yaml +# modules/classes/StaffRole.yaml (base class) +StaffRole: + abstract: true + description: Base class for staff role categories + slots: + - role_id + - role_name + - role_category + - typical_domains + +# modules/classes/StaffRoles.yaml (subclasses) +Curator: + is_a: StaffRole + description: Museum curator specializing in collection research... + +Conservator: + is_a: StaffRole + description: Conservator specializing in preservation... +``` + +### Step 2: Update Slot Ranges + +```yaml +# BEFORE (enum) +staff_role: + range: StaffRoleTypeEnum + +# AFTER (class) +staff_role: + range: StaffRole +``` + +### Step 3: Update Modular Schema Imports + +```yaml +# REMOVE enum import +# - modules/enums/StaffRoleTypeEnum # DELETED + +# ADD class imports +- modules/classes/StaffRole +- modules/classes/StaffRoles +``` + +### Step 4: Archive the Enum + +```bash +mkdir -p schemas/.../archive/enums +mv modules/enums/OldEnum.yaml archive/enums/OldEnum.yaml.archived_$(date +%Y%m%d) +``` + +### Step 5: Document the Change + +- Update `archive/enums/README.md` with migration entry +- Add comment in modular schema explaining removal +- Update any documentation referencing the old enum + +--- + +## Example: StaffRoleTypeEnum to StaffRole + +**Before** (2025-12-05): +```yaml +# StaffRoleTypeEnum.yaml +StaffRoleTypeEnum: + permissible_values: + CURATOR: + description: Museum curator + CONSERVATOR: + description: Conservator + # ... 51 values with limited documentation +``` + +**After** (2025-12-06): +```yaml +# StaffRole.yaml (abstract base) +StaffRole: + abstract: true + slots: + - role_id + - role_name + - role_category + - typical_domains + - typical_responsibilities + - requires_qualification + +# StaffRoles.yaml (51 subclasses) +Curator: + is_a: StaffRole + class_uri: schema:curator + description: | + Museum curator specializing in collection research... + + **IMPORTANT - FORMAL TITLE vs DE FACTO WORK**: + This is the OFFICIAL job appellation/title. Actual work may differ. + slot_usage: + role_category: + equals_string: CURATORIAL + typical_domains: + equals_expression: "[Museums, Galleries]" +``` + +**Why the promotion?** +1. Need to distinguish FORMAL TITLE from DE FACTO WORK +2. Each role has `role_category`, `common_variants`, `typical_domains`, `typical_responsibilities` +3. Roles benefit from inheritance (`Curator is_a StaffRole`) +4. Richer documentation per role + +--- + +## Enums That Should REMAIN Enums + +Some enums are appropriate as permanent fixtures: + +| Enum | Why Keep as Enum | +|------|------------------| +| `DataTierEnum` | Simple 4-value tier (TIER_1 through TIER_4), no properties needed | +| `DataSourceEnum` | Fixed source types, simple strings | +| `CountryCodeEnum` | ISO 3166-1 standard, no custom properties | +| `LanguageCodeEnum` | ISO 639 standard, no custom properties | + +**Characteristics of "permanent" enums**: +- Based on external standards (ISO, etc.) +- Simple values with no need for properties +- Unlikely to require rich per-value documentation +- Used purely for validation/constraint + +--- + +## Anti-Patterns + +### WRONG: Keep Both Enum and Classes + +```yaml +# modules/enums/StaffRoleTypeEnum.yaml # Still exists! +# modules/classes/StaffRole.yaml # Also exists! +# Which one is authoritative? CONFUSION! +``` + +### WRONG: Create Classes but Keep Enum "for backwards compatibility" + +```yaml +# "Let's keep the enum for old code" +# Result: Two sources of truth, guaranteed drift +``` + +### CORRECT: Delete Enum After Creating Classes + +```yaml +# modules/enums/StaffRoleTypeEnum.yaml # ARCHIVED +# modules/classes/StaffRole.yaml # Single source of truth +# modules/classes/StaffRoles.yaml # All 51 role subclasses +``` + +--- + +## Verification Checklist + +After promoting an enum to classes: + +- [ ] Old enum file moved to `archive/enums/` +- [ ] Modular schema import removed for enum +- [ ] Modular schema import added for new class(es) +- [ ] All slot ranges updated from enum to class +- [ ] No grep results for old enum name in active schema files +- [ ] `archive/enums/README.md` updated with migration entry +- [ ] Comment added in modular schema explaining removal + +```bash +# Verify enum is fully removed (should return only archive hits) +grep -r "StaffRoleTypeEnum" schemas/20251121/linkml/ +``` + +--- + +## See Also + +- `docs/ENUM_CLASS_SINGLE_SOURCE.md` - Extended documentation +- `schemas/20251121/linkml/archive/enums/README.md` - Archive directory +- LinkML documentation on enums: https://linkml.io/linkml/schemas/enums.html +- LinkML documentation on classes: https://linkml.io/linkml/schemas/models.html diff --git a/schemas/20251121/linkml/rules/GEONAMES_SETTLEMENT.md b/schemas/20251121/linkml/rules/GEONAMES_SETTLEMENT.md new file mode 100644 index 0000000000..1add177e09 --- /dev/null +++ b/schemas/20251121/linkml/rules/GEONAMES_SETTLEMENT.md @@ -0,0 +1,436 @@ +# GeoNames Settlement Standardization Rules + +**Rule ID**: GEONAMES-SETTLEMENT +**Status**: MANDATORY +**Applies To**: GHCID settlement component generation +**Version**: 1.1.0 +**Effective Date**: 2025-12-01 +**Last Updated**: 2025-12-01 + +--- + +## Purpose + +This document defines the rules for standardizing settlement names in GHCID (Global Heritage Custodian Identifier) generation using the GeoNames geographical database. + +## Core Principle + +**ALL settlement names in GHCID must be derived from GeoNames standardized names, not from source data.** + +The GeoNames database serves as the **single source of truth** for: +- Settlement names (cities, towns, villages) +- Settlement abbreviations/codes +- Administrative region codes (admin1) +- Geographic coordinates validation + +## Why GeoNames Standardization? + +1. **Consistency**: Same settlement = same GHCID component, regardless of source data variations +2. **Disambiguation**: Handles duplicate city names across regions +3. **Internationalization**: Provides ASCII-safe names for identifiers +4. **Authority**: GeoNames is a well-maintained, CC-licensed geographic database +5. **Persistence**: Settlement names don't change frequently, ensuring GHCID stability + +--- + +## CRITICAL: Feature Code Filtering + +**NEVER use neighborhoods or districts (PPLX) for GHCID generation. ONLY use proper settlements (cities, towns, villages).** + +GeoNames classifies populated places with feature codes. When reverse geocoding coordinates to find a settlement, you MUST filter by feature code. + +### ALLOWED Feature Codes + +| Code | Description | Example | +|------|-------------|---------| +| **PPL** | Populated place (city/town/village) | Apeldoorn, Hamont, Lelystad | +| **PPLA** | Seat of first-order admin division | Provincial capitals | +| **PPLA2** | Seat of second-order admin division | Municipal seats | +| **PPLA3** | Seat of third-order admin division | District seats | +| **PPLA4** | Seat of fourth-order admin division | Sub-district seats | +| **PPLC** | Capital of a political entity | Amsterdam, Brussels | +| **PPLS** | Populated places (multiple) | Settlement clusters | +| **PPLG** | Seat of government | The Hague | + +### EXCLUDED Feature Codes + +| Code | Description | Why Excluded | +|------|-------------|--------------| +| **PPLX** | Section of populated place | Neighborhoods, districts, quarters (e.g., "Binnenstad", "Amsterdam Binnenstad") | + +### Implementation + +```python +VALID_FEATURE_CODES = ('PPL', 'PPLA', 'PPLA2', 'PPLA3', 'PPLA4', 'PPLC', 'PPLS', 'PPLG') + +query = """ + SELECT name, feature_code, geonames_id, ... + FROM cities + WHERE country_code = ? + AND feature_code IN (?, ?, ?, ?, ?, ?, ?, ?) + ORDER BY distance_sq + LIMIT 1 +""" +cursor.execute(query, (country_code, *VALID_FEATURE_CODES)) +``` + +### Verification + +Always check `feature_code` in location_resolution metadata: + +```yaml +location_resolution: + geonames_name: Apeldoorn + feature_code: PPL # ← MUST be PPL, PPLA*, PPLC, PPLS, or PPLG +``` + +**If you see `feature_code: PPLX`**, the GHCID is WRONG and must be regenerated. + +--- + +## CRITICAL: Country Code Detection + +**Determine country code from entry data BEFORE calling GeoNames reverse geocoding.** + +GeoNames queries are country-specific. Using the wrong country code will return incorrect results. + +### Country Code Resolution Priority + +1. `zcbs_enrichment.country` - Most explicit source +2. `location.country` - Direct location field +3. `locations[].country` - Array location field +4. `original_entry.country` - CSV source field +5. `google_maps_enrichment.address` - Parse from address string +6. `wikidata_enrichment.located_in.label` - Infer from Wikidata +7. Default: `"NL"` (Netherlands) - Only if no other source + +### Example + +```python +# Determine country code FIRST +country_code = "NL" # Default + +if entry.get('zcbs_enrichment', {}).get('country'): + country_code = entry['zcbs_enrichment']['country'] +elif entry.get('google_maps_enrichment', {}).get('address', ''): + address = entry['google_maps_enrichment']['address'] + if ', Belgium' in address: + country_code = "BE" + elif ', Germany' in address: + country_code = "DE" + +# THEN call reverse geocoding +result = reverse_geocode_to_city(latitude, longitude, country_code) +``` + +--- + +## Settlement Resolution Process + +### Step 1: Coordinate-Based Resolution (Preferred) + +When coordinates are available, use reverse geocoding to find the nearest GeoNames settlement: + +```python +def resolve_settlement_from_coordinates(latitude: float, longitude: float, country_code: str = "NL") -> dict: + """ + Find the GeoNames settlement nearest to given coordinates. + + Returns: + { + 'settlement_name': 'Lelystad', # GeoNames standardized name + 'settlement_code': 'LEL', # 3-letter abbreviation + 'admin1_code': '16', # GeoNames admin1 code + 'region_code': 'FL', # ISO 3166-2 region code + 'geonames_id': 2751792, # GeoNames ID for provenance + 'distance_km': 0.5 # Distance from coords to settlement center + } + """ +``` + +### Step 2: Name-Based Resolution (Fallback) + +When only a settlement name is available (no coordinates), look up in GeoNames: + +```python +def resolve_settlement_from_name(name: str, country_code: str = "NL") -> dict: + """ + Find the GeoNames settlement matching the given name. + + Uses fuzzy matching and disambiguation when multiple matches exist. + """ +``` + +### Step 3: Manual Resolution (Last Resort) + +If GeoNames lookup fails, flag the entry for manual review with: +- `settlement_source: MANUAL` +- `settlement_needs_review: true` + +--- + +## GHCID Settlement Component Rules + +### Format + +The settlement component in GHCID uses a **3-letter uppercase code**: + +``` +NL-{REGION}-{SETTLEMENT}-{TYPE}-{ABBREV} + ^^^^^^^^^^^ + 3-letter code from GeoNames +``` + +### Code Generation Rules + +1. **Single-word settlements**: First 3 letters uppercase + - `Amsterdam` → `AMS` + - `Rotterdam` → `ROT` + - `Lelystad` → `LEL` + +2. **Settlements with Dutch articles** (`de`, `het`, `den`, `'s`): + - First letter of article + first 2 letters of main word + - `Den Haag` → `DHA` + - `'s-Hertogenbosch` → `SHE` + - `De Bilt` → `DBI` + +3. **Multi-word settlements** (no article): + - First letter of each word (up to 3) + - `Nieuw Amsterdam` → `NAM` + - `Oud Beijerland` → `OBE` + +4. **GeoNames Disambiguation Database**: + - For known problematic settlements, use pre-defined codes from disambiguation table + - Example: Both `Zwolle` (OV) and `Zwolle` (LI) exist - use `ZWO` with region for uniqueness + +### Measurement Point for Historical Custodians + +**Rule**: For heritage custodians that no longer exist or have historical coordinates, the **modern-day settlement** (as of 2025-12-01) is used. + +Rationale: +- GHCIDs should be stable over time +- Historical place names may have changed +- Modern settlements are easier to verify and look up +- GeoNames reflects current geographic reality + +Example: +- A museum that operated 1900-1950 in what was then "Nieuw Land" (before Flevoland province existed) +- Modern coordinates fall within Lelystad municipality +- GHCID uses `LEL` (Lelystad) as settlement code, not historical name + +--- + +## GeoNames Database Integration + +### Database Location + +``` +/data/reference/geonames.db +``` + +### Required Tables + +```sql +-- Cities/settlements table +CREATE TABLE cities ( + geonames_id INTEGER PRIMARY KEY, + name TEXT, -- Local name (may have diacritics) + ascii_name TEXT, -- ASCII-safe name for identifiers + country_code TEXT, -- ISO 3166-1 alpha-2 + admin1_code TEXT, -- First-level administrative division + admin1_name TEXT, -- Region/province name + latitude REAL, + longitude REAL, + population INTEGER, + feature_code TEXT -- PPL, PPLA, PPLC, etc. +); + +-- Disambiguation table for problematic settlements +CREATE TABLE settlement_codes ( + geonames_id INTEGER PRIMARY KEY, + country_code TEXT, + settlement_code TEXT, -- 3-letter code + is_primary BOOLEAN, -- Primary code for this settlement + notes TEXT +); +``` + +### Admin1 Code Mapping (Netherlands) + +**IMPORTANT**: GeoNames admin1 codes differ from historical numbering. Use this mapping: + +| GeoNames admin1 | Province | ISO 3166-2 | +|-----------------|----------|------------| +| 01 | Drenthe | NL-DR | +| 02 | Friesland | NL-FR | +| 03 | Gelderland | NL-GE | +| 04 | Groningen | NL-GR | +| 05 | Limburg | NL-LI | +| 06 | Noord-Brabant | NL-NB | +| 07 | Noord-Holland | NL-NH | +| 09 | Utrecht | NL-UT | +| 10 | Zeeland | NL-ZE | +| 11 | Zuid-Holland | NL-ZH | +| 15 | Overijssel | NL-OV | +| 16 | Flevoland | NL-FL | + +**Note**: Code 08 is not used in Netherlands (was assigned to former region). + +--- + +## Validation Requirements + +### Before GHCID Generation + +Every entry MUST have: +- [ ] Settlement name resolved via GeoNames +- [ ] `geonames_id` recorded in entry metadata +- [ ] Settlement code (3-letter) generated consistently +- [ ] Admin1/region code mapped correctly + +### Provenance Tracking + +Record GeoNames resolution in entry metadata: + +```yaml +location_resolution: + method: REVERSE_GEOCODE # or NAME_LOOKUP or MANUAL + geonames_id: 2751792 + geonames_name: Lelystad + settlement_code: LEL + admin1_code: "16" + region_code: FL + resolution_date: "2025-12-01T00:00:00Z" + source_coordinates: + latitude: 52.52111 + longitude: 5.43722 + distance_to_settlement_km: 0.5 +``` + +--- + +## CRITICAL: XXX Placeholders Are TEMPORARY - Research Required + +**XXX placeholders for region/settlement codes are NEVER acceptable as a final state.** + +When an entry has `XX` (unknown region) or `XXX` (unknown settlement), the agent MUST conduct research to resolve the location. + +### Resolution Strategy by Institution Type + +| Institution Type | Location Resolution Method | +|------------------|---------------------------| +| **Destroyed institution** | Use last known physical location before destruction | +| **Historical (closed)** | Use last operating location | +| **Refugee/diaspora org** | Use current headquarters OR original founding location | +| **Digital-only platform** | Use parent/founding organization's headquarters | +| **Decentralized initiative** | Use founding location or primary organizer location | +| **Unknown city, known country** | Research via Wikidata, Google Maps, official website | + +### Research Sources (Priority Order) + +1. **Wikidata** - P131 (located in), P159 (headquarters location), P625 (coordinates) +2. **Google Maps** - Search institution name +3. **Official Website** - Contact page, about page +4. **Web Archive** - archive.org for destroyed/closed institutions +5. **Academic Sources** - Papers, reports +6. **News Articles** - Particularly for destroyed heritage sites + +### Location Resolution Metadata + +When resolving XXX placeholders, update `location_resolution`: + +```yaml +location_resolution: + method: MANUAL_RESEARCH # Previously was NAME_LOOKUP with XXX + country_code: PS + region_code: GZ + region_name: Gaza Strip + city_code: GAZ + city_name: Gaza City + geonames_id: 281133 + research_date: "2025-12-06T00:00:00Z" + research_sources: + - type: wikidata + id: Q123456 + claim: P131 + - type: web_archive + url: https://web.archive.org/web/20231001/https://institution-website.org/contact + notes: "Located in Gaza City prior to destruction in 2024" +``` + +### File Renaming After Resolution + +When GHCID changes due to XXX resolution, the file MUST be renamed: + +```bash +# Before +data/custodian/PS-XX-XXX-A-NAPR.yaml + +# After +data/custodian/PS-GZ-GAZ-A-NAPR.yaml +``` + +### Prohibited Practices + +- ❌ Leaving XXX placeholders in production data +- ❌ Using "Online" or country name as location +- ❌ Skipping research because it's difficult +- ❌ Using XX/XXX for diaspora organizations + +--- + +## Error Handling + +### No GeoNames Match + +If a settlement cannot be resolved via automated lookup: +1. Log warning with entry details +2. Set `settlement_code: XXX` (temporary placeholder) +3. Set `settlement_needs_review: true` +4. Do NOT skip the entry - generate GHCID with XXX placeholder +5. **IMMEDIATELY** begin manual research to resolve + +### Multiple GeoNames Matches + +When multiple settlements match a name: +1. Use coordinates to disambiguate (if available) +2. Use admin1/region context (if available) +3. Use population as tiebreaker (prefer larger settlement) +4. Flag for manual review if still ambiguous + +### Coordinates Outside Country + +If coordinates fall outside the expected country: +1. Log warning +2. Use nearest settlement within country +3. Flag for manual review + +--- + +## Related Documentation + +- `AGENTS.md` - Section on GHCID generation +- `docs/PERSISTENT_IDENTIFIERS.md` - Complete GHCID specification +- `docs/GHCID_PID_SCHEME.md` - PID scheme details +- `scripts/enrich_nde_entries_ghcid.py` - Implementation + +--- + +## Changelog + +### v1.1.0 (2025-12-01) +- **CRITICAL**: Added feature code filtering rules + - MUST filter for PPL, PPLA, PPLA2, PPLA3, PPLA4, PPLC, PPLS, PPLG + - MUST exclude PPLX (neighborhoods/districts) + - Example: Apeldoorn (PPL) not "Binnenstad" (PPLX) +- **CRITICAL**: Added country code detection rules + - Must determine country from entry data BEFORE reverse geocoding + - Priority: zcbs_enrichment.country > location.country > address parsing + - Example: Belgian institutions use BE, not NL +- Added Belgium admin1 code mapping (BRU, VLG, WAL) + +### v1.0.0 (2025-12-01) +- Initial version +- Established GeoNames as authoritative source for settlement standardization +- Defined measurement point rule for historical custodians +- Documented admin1 code mapping for Netherlands diff --git a/schemas/20251121/linkml/rules/LEGAL_FORM_FILTER.md b/schemas/20251121/linkml/rules/LEGAL_FORM_FILTER.md new file mode 100644 index 0000000000..10cf384416 --- /dev/null +++ b/schemas/20251121/linkml/rules/LEGAL_FORM_FILTER.md @@ -0,0 +1,346 @@ +# Legal Form Filtering Rule for CustodianName + +**Rule ID**: LEGAL-FORM-FILTER +**Status**: MANDATORY +**Applies To**: CustodianName standardization +**Created**: 2025-12-02 + +--- + +## Overview + +**CRITICAL RULE**: Legal form designations MUST ALWAYS be filtered from `CustodianName`, even when the custodian self-identifies with them. + +This is the **ONE EXCEPTION** to the emic (insider name) principle in the Heritage Custodian Ontology. + +## Rationale + +### Why Legal Forms Are NOT Part of Identity + +1. **Legal Form ≠ Identity**: The legal structure is administrative metadata, not the custodian's core identity + - "Stichting Rijksmuseum" → Identity is "Rijksmuseum", legal form is "Stichting" + +2. **Legal Forms Change Over Time**: Organizations transform while identity persists + - Association → Foundation → Corporation (same museum, different legal structures) + +3. **Cross-Jurisdictional Consistency**: Same organization may have different legal forms in different countries + - "Getty Foundation" (US) = "Stichting Getty" (NL) = same identity + +4. **Deduplication**: Prevents false duplicates + - "Museum X" and "Stichting Museum X" should NOT be separate entities + +5. **ISO 20275 Alignment**: The Legal Entity Identifier (LEI) standard explicitly separates legal form from entity name + +### Where Legal Form IS Stored + +Legal form information is NOT discarded - it is stored in appropriate metadata fields: + +| Field | Location | Purpose | +|-------|----------|---------| +| `legal_form` | `CustodianLegalStatus` | ISO 20275 legal form code | +| `legal_name` | `CustodianLegalStatus` | Full registered name including legal form | +| `observed_name` | `CustodianObservation` | Original name as observed in source (may include legal form) | + +## Examples + +### Dutch Examples + +| Source Name | CustodianName | Legal Form | Notes | +|-------------|---------------|------------|-------| +| Stichting Rijksmuseum | Rijksmuseum | Stichting | Prefix removal | +| Hidde Nijland Stichting | Hidde Nijland | Stichting | Suffix removal | +| Stichting Het Loo | Het Loo | Stichting | Preserve article "Het" | +| Coöperatie Erfgoed | Erfgoed | Coöperatie | | +| Vereniging Ons Huis | Ons Huis | Vereniging | | +| Museum B.V. | Museum | B.V. | | + +### International Examples + +| Source Name | CustodianName | Legal Form | Language | +|-------------|---------------|------------|----------| +| The Getty Foundation | The Getty | Foundation | English | +| British Museum Trust Ltd | British Museum | Trust Ltd | English | +| Smithsonian Institution Inc. | Smithsonian Institution | Inc. | English | +| Fundação Biblioteca Nacional | Biblioteca Nacional | Fundação | Portuguese | +| Verein Deutsches Museum | Deutsches Museum | Verein | German | +| Association des Amis du Louvre | Amis du Louvre | Association | French | +| Fondazione Musei Civici | Musei Civici | Fondazione | Italian | +| Fundación Museo del Prado | Museo del Prado | Fundación | Spanish | + +--- + +## Global Legal Form Terms Reference + +### Dutch (Netherlands, Belgium-Flanders) + +**Foundations and Non-Profits:** +- Stichting (foundation) +- Vereniging (association) +- Coöperatie, Coöperatieve (cooperative) + +**Business Entities:** +- B.V., BV (besloten vennootschap - private limited company) +- N.V., NV (naamloze vennootschap - public limited company) +- V.O.F., VOF (vennootschap onder firma - general partnership) +- C.V., CV (commanditaire vennootschap - limited partnership) +- Maatschap (partnership) +- Eenmanszaak (sole proprietorship) + +### English (UK, US, Ireland, Australia, etc.) + +**Foundations and Non-Profits:** +- Foundation +- Trust +- Association +- Society +- Institute +- Institution (when followed by Inc./Ltd.) +- Charity +- Fund + +**Business Entities:** +- Inc., Incorporated +- Ltd., Limited +- LLC, L.L.C. (limited liability company) +- LLP, L.L.P. (limited liability partnership) +- Corp., Corporation +- Co., Company +- PLC, plc (public limited company - UK) +- Pty Ltd (proprietary limited - Australia) + +### German (Germany, Austria, Switzerland) + +**Foundations and Non-Profits:** +- Stiftung (foundation) +- Verein (association) +- e.V., eingetragener Verein (registered association) +- gGmbH (gemeinnützige GmbH - charitable limited company) + +**Business Entities:** +- GmbH (Gesellschaft mit beschränkter Haftung - limited liability company) +- AG (Aktiengesellschaft - stock corporation) +- KG (Kommanditgesellschaft - limited partnership) +- OHG (offene Handelsgesellschaft - general partnership) +- GmbH & Co. KG +- UG (Unternehmergesellschaft - mini-GmbH) + +### French (France, Belgium-Wallonia, Switzerland, Canada-Quebec) + +**Foundations and Non-Profits:** +- Fondation (foundation) +- Association (association) +- Fonds (fund) + +**Business Entities:** +- S.A., SA (société anonyme - public limited company) +- S.A.R.L., SARL (société à responsabilité limitée - private limited company) +- S.A.S., SAS (société par actions simplifiée) +- S.C.I., SCI (société civile immobilière) +- S.N.C., SNC (société en nom collectif - general partnership) +- S.C.S., SCS (société en commandite simple) +- EURL (entreprise unipersonnelle à responsabilité limitée) + +### Spanish (Spain, Latin America) + +**Foundations and Non-Profits:** +- Fundación (foundation) +- Asociación (association) +- Sociedad (society) - when not followed by commercial designator + +**Business Entities:** +- S.A., SA (sociedad anónima - public limited company) +- S.L., SL (sociedad limitada - private limited company) +- S.L.L., SLL (sociedad limitada laboral) +- S.Coop. (sociedad cooperativa) +- S.C., SC (sociedad colectiva - general partnership) +- S.Com., S. en C. (sociedad en comandita) + +### Portuguese (Portugal, Brazil) + +**Foundations and Non-Profits:** +- Fundação (foundation) +- Associação (association) +- Instituto (institute) + +**Business Entities:** +- Ltda., Limitada (limited liability company) +- S.A., SA (sociedade anônima - corporation) +- S/A +- Cia., Companhia (company) +- ME (microempresa) +- EPP (empresa de pequeno porte) + +### Italian (Italy, Switzerland-Ticino) + +**Foundations and Non-Profits:** +- Fondazione (foundation) +- Associazione (association) +- Ente (entity/institution) +- Onlus (non-profit organization) + +**Business Entities:** +- S.p.A., SpA (società per azioni - joint-stock company) +- S.r.l., Srl (società a responsabilità limitata - limited liability company) +- S.a.s., Sas (società in accomandita semplice) +- S.n.c., Snc (società in nome collettivo) +- S.c.a.r.l. (società cooperativa a responsabilità limitata) + +### Scandinavian Languages + +**Danish:** +- Fond (foundation) +- Forening (association) +- A/S (aktieselskab - public limited company) +- ApS (anpartsselskab - private limited company) + +**Swedish:** +- Stiftelse (foundation) +- Förening (association) +- AB (aktiebolag - limited company) + +**Norwegian:** +- Stiftelse (foundation) +- Forening (association) +- AS (aksjeselskap - limited company) +- ASA (allmennaksjeselskap - public limited company) + +### Other European Languages + +**Polish:** +- Fundacja (foundation) +- Stowarzyszenie (association) +- Sp. z o.o. (limited liability company) +- S.A. (joint-stock company) + +**Czech:** +- Nadace (foundation) +- Spolek (association) +- s.r.o. (limited liability company) +- a.s. (joint-stock company) + +**Hungarian:** +- Alapítvány (foundation) +- Egyesület (association) +- Kft. (limited liability company) +- Zrt. (private limited company) +- Nyrt. (public limited company) + +**Greek:** +- Ίδρυμα (Idryma - foundation) +- Σύλλογος (Syllogos - association) +- Α.Ε., ΑΕ (Ανώνυμη Εταιρεία - corporation) +- Ε.Π.Ε., ΕΠΕ (limited liability company) + +**Finnish:** +- Säätiö (foundation) +- Yhdistys (association) +- Oy (osakeyhtiö - limited company) +- Oyj (public limited company) + +### Asian Languages + +**Japanese:** +- 財団法人 (zaidan hōjin - incorporated foundation) +- 社団法人 (shadan hōjin - incorporated association) +- 株式会社, K.K. (kabushiki kaisha - corporation) +- 合同会社, G.K. (gōdō kaisha - LLC) +- 有限会社, Y.K. (yūgen kaisha - limited company) + +**Chinese:** +- 基金会 (jījīn huì - foundation) +- 协会 (xiéhuì - association) +- 有限公司 (yǒuxiàn gōngsī - limited company) +- 股份有限公司 (gǔfèn yǒuxiàn gōngsī - joint-stock company) + +**Korean:** +- 재단법인 (jaedan beobin - incorporated foundation) +- 사단법인 (sadan beobin - incorporated association) +- 주식회사 (jusik hoesa - corporation) +- 유한회사 (yuhan hoesa - limited company) + +### Middle Eastern Languages + +**Arabic:** +- مؤسسة (mu'assasa - foundation/institution) +- جمعية (jam'iyya - association) +- شركة (sharika - company) +- ش.م.م (limited liability company) +- ش.م.ع (public joint-stock company) + +**Hebrew:** +- עמותה (amuta - non-profit association) +- חל"צ (company for public benefit) +- בע"מ (limited company) + +**Turkish:** +- Vakıf (foundation) +- Dernek (association) +- A.Ş. (anonim şirket - joint-stock company) +- Ltd. Şti. (limited şirket - limited company) + +### Latin American Specific + +**Brazilian Portuguese:** +- OSCIP (organização da sociedade civil de interesse público) +- ONG (organização não governamental) +- EIRELI (empresa individual de responsabilidade limitada) + +**Mexican Spanish:** +- A.C. (asociación civil - civil association) +- S.C. (sociedad civil) +- S. de R.L. (sociedad de responsabilidad limitada) + +--- + +## Implementation Guidelines + +### Filtering Algorithm + +```python +def filter_legal_form(name: str, language: str = None) -> tuple[str, str | None]: + """ + Remove legal form terms from custodian name. + + Returns: + tuple: (filtered_name, legal_form_found) + """ + # Apply language-specific patterns first if language known + # Then apply universal patterns + # Handle both prefix and suffix positions + # Preserve articles (the, het, de, la, le, etc.) + pass +``` + +### Position Handling + +Legal forms can appear as: + +1. **Prefix**: "Stichting Rijksmuseum" → Remove "Stichting " +2. **Suffix**: "British Museum Trust Ltd" → Remove " Trust Ltd" +3. **Infix** (rare): Handle case-by-case + +### Edge Cases + +1. **Multiple legal forms**: "Foundation Trust Ltd" → Remove all +2. **Abbreviation variations**: "Inc." = "Inc" = "Incorporated" +3. **Case insensitivity**: "STICHTING" = "Stichting" = "stichting" +4. **With punctuation**: "B.V." = "BV" = "B.V" +5. **Compound terms**: "GmbH & Co. KG" → Remove entire compound + +### Validation Script + +Use `scripts/validate_organization_names.py` to detect names that still contain legal form terms after filtering. + +--- + +## References + +- ISO 20275:2017 - Financial services — Entity legal forms (ELF) +- GLEIF Legal Entity Identifier documentation +- LinkML Schema: `schemas/20251121/linkml/modules/classes/CustodianName.yaml` +- AGENTS.md: Rule 8 (Legal Form Filtering) + +--- + +**Last Updated**: 2025-12-02 +**Maintained By**: GLAM Heritage Custodian Ontology Project diff --git a/schemas/20251121/linkml/rules/README.md b/schemas/20251121/linkml/rules/README.md new file mode 100644 index 0000000000..4bb4277d0a --- /dev/null +++ b/schemas/20251121/linkml/rules/README.md @@ -0,0 +1,156 @@ +# Value Standardization Rules + +**Location**: `schemas/20251121/linkml/rules/` +**Purpose**: Data transformation and processing rules for achieving standardized values required by Heritage Custodian (HC) classes. + +--- + +## About These Rules + +These rules are **formally outside the LinkML schema convention** but document HOW data values are: +- Transformed +- Converted +- Processed +- Normalized + +to achieve the standardized values required by particular HC classes. + +**IMPORTANT**: These are NOT LinkML validation rules. They are **processing instructions** for data pipelines and extraction agents. + +--- + +## Rule Categories + +### 1. Name Standardization Rules + +| Rule ID | File | Applies To | Summary | +|---------|------|------------|---------| +| **LEGAL-FORM-FILTER** | [`LEGAL_FORM_FILTER.md`](LEGAL_FORM_FILTER.md) | `CustodianName` | Remove legal form terms (Stichting, Foundation, Inc.) from emic names | +| **ABBREV-CHAR-FILTER** | [`ABBREVIATION_RULES.md`](ABBREVIATION_RULES.md) | GHCID abbreviation | Remove special characters (&, /, +, @) and normalize diacritics to ASCII | +| **TRANSLIT-ISO** | [`TRANSLITERATION.md`](TRANSLITERATION.md) | GHCID abbreviation | Transliterate non-Latin scripts (Cyrillic, CJK, Arabic) using ISO standards | + +### 2. Geographic Standardization Rules + +| Rule ID | File | Applies To | Summary | +|---------|------|------------|---------| +| **GEONAMES-SETTLEMENT** | [`GEONAMES_SETTLEMENT.md`](GEONAMES_SETTLEMENT.md) | Settlement codes | Use GeoNames as single source for settlement names | +| **FEATURE-CODE-FILTER** | [`GEONAMES_SETTLEMENT.md`](GEONAMES_SETTLEMENT.md) | Reverse geocoding | Only use PPL* feature codes, never PPLX (neighborhoods) | + +### 3. Web Observation Rules + +| Rule ID | File | Applies To | Summary | +|---------|------|------------|---------| +| **XPATH-PROVENANCE** | [`XPATH_PROVENANCE.md`](XPATH_PROVENANCE.md) | `WebClaim` | Every web claim MUST have XPath pointer to archived HTML | + +### 4. Schema Evolution Rules + +| Rule ID | File | Applies To | Summary | +|---------|------|------------|---------| +| **ENUM-TO-CLASS** | [`ENUM_TO_CLASS.md`](ENUM_TO_CLASS.md) | Enums/Classes | When enum promoted to class hierarchy, delete original enum | + +--- + +## GLAMORCUBESFIXPHDNT Taxonomy Applicability + +Each rule primarily applies to certain custodian types: + +| Rule | Primary Types | All Types | +|------|--------------|-----------| +| LEGAL-FORM-FILTER | All | ✅ | +| ABBREV-SPECIAL-CHAR | All | ✅ | +| ABBREV-DIACRITICS | All | ✅ | +| TRANSLITERATION | International (non-Latin script countries) | Partial | +| GEONAMES-SETTLEMENT | All | ✅ | +| XPATH-PROVENANCE | D (Digital platforms) | Partial | + +--- + +## Integration with bronhouder.nl + +These rules are displayed under a separate "Regels" (Rules) category on the bronhouder.nl LinkML visualization page, distinct from: +- Classes +- Slots +- Enums +- Instances + +Each rule includes: +- Rule ID (short identifier) +- Applicable class(es) +- GLAMORCUBESFIXPHDNT type indicator +- Transformation examples +- Implementation code (Python) + +--- + +## Rule Template + +New rules should follow this template: + +```markdown +# Rule Title + +**Rule ID**: SHORT-ID +**Status**: MANDATORY | RECOMMENDED | OPTIONAL +**Applies To**: Class or slot name +**Created**: YYYY-MM-DD +**Updated**: YYYY-MM-DD + +--- + +## Summary + +One-paragraph summary of what this rule does. + +--- + +## Rationale + +Why this rule exists (numbered list of reasons). + +--- + +## Specification + +Detailed specification with examples. + +--- + +## Implementation + +Python code showing how to implement this rule. + +--- + +## Examples + +| Input | Output | Explanation | +|-------|--------|-------------| + +--- + +## Related Rules + +- Other related rules + +--- + +## Changelog + +| Date | Change | +|------|--------| +``` + +--- + +## File List + +``` +rules/ +├── README.md # This file (rule index) +├── ABBREVIATION_RULES.md # ABBREV-CHAR-FILTER: Special char + diacritics normalization +├── LEGAL_FORM_FILTER.md # LEGAL-FORM-FILTER: Legal form removal from emic names +├── GEONAMES_SETTLEMENT.md # GEONAMES-SETTLEMENT: Geographic standardization via GeoNames +├── XPATH_PROVENANCE.md # XPATH-PROVENANCE: WebClaim XPath requirements +├── TRANSLITERATION.md # TRANSLIT-ISO: Non-Latin script transliteration +└── ENUM_TO_CLASS.md # ENUM-TO-CLASS: Schema evolution pattern +``` diff --git a/schemas/20251121/linkml/rules/TRANSLITERATION.md b/schemas/20251121/linkml/rules/TRANSLITERATION.md new file mode 100644 index 0000000000..6819782d8f --- /dev/null +++ b/schemas/20251121/linkml/rules/TRANSLITERATION.md @@ -0,0 +1,337 @@ +# Transliteration Standards for Non-Latin Scripts + +**Rule ID**: TRANSLIT-ISO +**Status**: MANDATORY +**Applies To**: GHCID abbreviation generation from emic names in non-Latin scripts +**Created**: 2025-12-08 + +--- + +## Summary + +**When generating GHCID abbreviations from institution names written in non-Latin scripts, the emic name MUST first be transliterated to Latin characters using the designated ISO or recognized standard for that script.** + +This rule affects **170 institutions** across **21 languages** with non-Latin writing systems. + +### Key Principles + +1. **Emic name is preserved** - The original script is stored in `custodian_name.emic_name` +2. **Transliteration is for processing only** - Used to generate abbreviations +3. **ISO/recognized standards required** - No ad-hoc romanization +4. **Deterministic output** - Same input always produces same Latin output +5. **Existing GHCIDs grandfathered** - Only applies to NEW custodians + +--- + +## Transliteration Standards by Script/Language + +### Cyrillic Scripts + +| Language | ISO Code | Standard | Library/Tool | Notes | +|----------|----------|----------|--------------|-------| +| **Russian** | ru | ISO 9:1995 | `transliterate` | Scientific transliteration | +| **Ukrainian** | uk | ISO 9:1995 | `transliterate` | Includes Ukrainian-specific letters | +| **Bulgarian** | bg | ISO 9:1995 | `transliterate` | Uses same Cyrillic base | +| **Serbian** | sr | ISO 9:1995 | `transliterate` | Serbian Cyrillic variant | +| **Kazakh** | kk | ISO 9:1995 | `transliterate` | Cyrillic-based (pre-2023) | + +**Example**: +``` +Input: Институт восточных рукописей РАН +ISO 9: Institut vostocnyh rukopisej RAN +Abbrev: IVRRAN (after diacritic normalization) +``` + +--- + +### CJK Scripts + +#### Chinese (Hanzi) + +| Variant | Standard | Library/Tool | Notes | +|---------|----------|--------------|-------| +| Simplified | Hanyu Pinyin (ISO 7098) | `pypinyin` | Standard PRC romanization | +| Traditional | Hanyu Pinyin | `pypinyin` | Same standard applies | + +**Pinyin Rules**: +- Tone marks are OMITTED for abbreviation (diacritics removed anyway) +- Word boundaries follow natural spacing +- Proper nouns capitalized + +**Example**: +``` +Input: 东巴文化博物院 +Pinyin: Dongba Wenhua Bowuyuan +ASCII: Dongba Wenhua Bowuyuan +Abbrev: DWB +``` + +#### Japanese (Kanji/Kana) + +| Standard | Library/Tool | Notes | +|----------|--------------|-------| +| Modified Hepburn | `pykakasi`, `romkan` | Most widely used internationally | + +**Hepburn Rules**: +- Long vowels: o, u (normalized to o, u for abbreviation) +- Particles: ha (wa), wo (wo), he (e) +- Syllabic n: n = n (before vowels: n') + +**Example**: +``` +Input: 国立中央博物館 +Romaji: Kokuritsu Chuo Hakubutsukan +ASCII: Kokuritsu Chuo Hakubutsukan +Abbrev: KCH +``` + +#### Korean (Hangul) + +| Standard | Library/Tool | Notes | +|----------|--------------|-------| +| Revised Romanization (RR) | `korean-romanizer`, `hangul-romanize` | Official South Korean standard (2000) | + +**RR Rules**: +- No diacritics (unlike McCune-Reischauer) +- Consonant assimilation reflected in spelling +- Word boundaries at natural breaks + +**Example**: +``` +Input: 독립기념관 +RR: Dongnip Ginyeomgwan +Abbrev: DG +``` + +--- + +### Arabic Script + +| Language | ISO Code | Standard | Library/Tool | Notes | +|----------|----------|----------|--------------|-------| +| **Arabic** | ar | ISO 233-2:1993 | `arabic-transliteration` | Simplified standard | +| **Persian/Farsi** | fa | ISO 233-3:1999 | `persian-transliteration` | Persian extensions | +| **Urdu** | ur | ISO 233-3 + Urdu extensions | `urdu-transliteration` | Additional characters | + +**Example (Arabic)**: +``` +Input: المكتبة الوطنية للمملكة المغربية +ISO: al-Maktaba al-Wataniya lil-Mamlaka al-Maghribiya +ASCII: al-Maktaba al-Wataniya lil-Mamlaka al-Maghribiya +Abbrev: MWMM (skip "al-" articles) +``` + +--- + +### Hebrew Script + +| Standard | Library/Tool | Notes | +|----------|--------------|-------| +| ISO 259-3:1999 | `hebrew-transliteration` | Simplified romanization | + +**Example**: +``` +Input: ארכיון הסיפור העממי בישראל +ISO: Arkhiyon ha-Sipur ha-Amami be-Yisrael +ASCII: Arkhiyon ha-Sipur ha-Amami be-Yisrael +Abbrev: ASAY (skip "ha-" and "be-" articles) +``` + +--- + +### Greek Script + +| Standard | Library/Tool | Notes | +|----------|--------------|-------| +| ISO 843:1997 | `greek-transliteration` | Romanization of Greek | + +**Example**: +``` +Input: Αρχαιολογικό Μουσείο Θεσσαλονίκης +ISO: Archaiologiko Mouseio Thessalonikis +ASCII: Archaiologiko Mouseio Thessalonikis +Abbrev: AMT +``` + +--- + +### Indic Scripts + +| Language | Script | Standard | Library/Tool | +|----------|--------|----------|--------------| +| **Hindi** | Devanagari | ISO 15919 | `indic-transliteration` | +| **Bengali** | Bengali | ISO 15919 | `indic-transliteration` | +| **Nepali** | Devanagari | ISO 15919 | `indic-transliteration` | +| **Sinhala** | Sinhala | ISO 15919 | `indic-transliteration` | + +**Example (Hindi)**: +``` +Input: राजस्थान प्राच्यविद्या प्रतिष्ठान +ISO: Rajasthana Pracyavidya Pratishthana +ASCII: Rajasthana Pracyavidya Pratishthana +Abbrev: RPP +``` + +--- + +### Southeast Asian Scripts + +| Language | Script | Standard | Library/Tool | +|----------|--------|----------|--------------| +| **Thai** | Thai | ISO 11940-2 | `thai-romanization` | +| **Khmer** | Khmer | ALA-LC | `khmer-romanization` | + +**Thai Example**: +``` +Input: สำนักหอจดหมายเหตุแห่งชาติ +ISO: Samnak Ho Chotmaihet Haeng Chat +Abbrev: SHCHC +``` + +--- + +### Other Scripts + +| Language | Script | Standard | Library/Tool | +|----------|--------|----------|--------------| +| **Armenian** | Armenian | ISO 9985 | `armenian-transliteration` | +| **Georgian** | Georgian | ISO 9984 | `georgian-transliteration` | + +**Georgian Example**: +``` +Input: ხელნაწერთა ეროვნული ცენტრი +ISO: Khelnawerti Erovnuli Centri +ASCII: Khelnawerti Erovnuli Centri +Abbrev: KEC +``` + +--- + +## Implementation + +### Python Transliteration Utility + +```python +import unicodedata +from typing import Optional + +def detect_script(text: str) -> str: + """ + Detect the primary script of the input text. + + Returns one of: 'latin', 'cyrillic', 'chinese', 'japanese', + 'korean', 'arabic', 'hebrew', 'greek', 'devanagari', etc. + """ + script_ranges = { + 'cyrillic': (0x0400, 0x04FF), + 'arabic': (0x0600, 0x06FF), + 'hebrew': (0x0590, 0x05FF), + 'devanagari': (0x0900, 0x097F), + 'thai': (0x0E00, 0x0E7F), + 'greek': (0x0370, 0x03FF), + 'korean': (0xAC00, 0xD7AF), + 'chinese': (0x4E00, 0x9FFF), + } + + for char in text: + code = ord(char) + for script, (start, end) in script_ranges.items(): + if start <= code <= end: + return script + + return 'latin' + + +def transliterate_for_abbreviation(emic_name: str, lang: str) -> str: + """ + Transliterate emic name for GHCID abbreviation generation. + + Args: + emic_name: Institution name in original script + lang: ISO 639-1 language code + + Returns: + Transliterated name ready for abbreviation extraction + """ + import re + + # Step 1: Transliterate to Latin (implementation depends on script) + latin = transliterate(emic_name, lang) + + # Step 2: Normalize diacritics + normalized = unicodedata.normalize('NFD', latin) + ascii_text = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn') + + # Step 3: Remove special characters (except spaces) + clean = re.sub(r'[^a-zA-Z\s]', ' ', ascii_text) + + # Step 4: Normalize whitespace + clean = ' '.join(clean.split()) + + return clean +``` + +--- + +## Skip Words by Language + +When extracting abbreviations from transliterated text, skip these articles/prepositions: + +### Arabic +- `al-` (the definite article) +- `bi-`, `li-`, `fi-` (prepositions) + +### Hebrew +- `ha-` (the) +- `ve-` (and) +- `be-`, `le-`, `me-` (prepositions) + +### Persian +- `-e`, `-ye` (ezafe connector) +- `va` (and) + +### CJK Languages +- No skip words (particles are integral to meaning) + +### Indic Languages +- `ka`, `ki`, `ke` (Hindi: of) +- `aur` (Hindi: and) + +--- + +## Validation + +### Check Transliteration Output + +```python +def validate_transliteration(result: str) -> bool: + """ + Validate that transliteration output contains only ASCII letters and spaces. + """ + import re + return bool(re.match(r'^[a-zA-Z\s]+$', result)) +``` + +### Manual Review Queue + +Non-Latin institutions should be flagged for manual review if: +1. Transliteration library not available for that script +2. Confidence in transliteration is low +3. Institution has multiple official romanizations + +--- + +## Related Documentation + +- `AGENTS.md` - Rule 12: Transliteration Standards +- `rules/ABBREVIATION_RULES.md` - Character filtering after transliteration +- `docs/TRANSLITERATION_CONVENTIONS.md` - Extended examples and edge cases +- `scripts/transliterate_emic_names.py` - Production transliteration script + +--- + +## Changelog + +| Date | Change | +|------|--------| +| 2025-12-08 | Initial standards document created | diff --git a/schemas/20251121/linkml/rules/XPATH_PROVENANCE.md b/schemas/20251121/linkml/rules/XPATH_PROVENANCE.md new file mode 100644 index 0000000000..3581453776 --- /dev/null +++ b/schemas/20251121/linkml/rules/XPATH_PROVENANCE.md @@ -0,0 +1,210 @@ +# WebObservation XPath Provenance Rules + +**Rule ID**: XPATH-PROVENANCE +**Status**: MANDATORY +**Applies To**: WebClaim extraction from websites +**Created**: 2025-11-29 + +--- + +## Core Principle: Every Claim MUST Have Verifiable Provenance + +**If a claim allegedly came from a webpage, it MUST have an XPath pointer to the exact location in the archived HTML where that value appears. Claims without XPath provenance are considered FABRICATED and must be removed.** + +This is not about "confidence" or "uncertainty" - it's about **verifiability**. Either the claim value exists in the HTML at a specific XPath, or it was hallucinated/fabricated by an LLM. + +--- + +## Required Fields for WebObservation Claims + +Every claim in `web_enrichment.claims` MUST have: + +| Field | Required | Description | +|-------|----------|-------------| +| `claim_type` | YES | Type of claim (full_name, description, email, etc.) | +| `claim_value` | YES | The extracted value | +| `source_url` | YES | URL the claim was extracted from | +| `retrieved_on` | YES | ISO 8601 timestamp when page was archived | +| `xpath` | YES | XPath to the element containing this value | +| `html_file` | YES | Relative path to archived HTML file | +| `xpath_match_score` | YES | 1.0 for exact match, <1.0 for fuzzy match | + +### Example - CORRECT (Verifiable) + +```yaml +web_enrichment: + claims: + - claim_type: full_name + claim_value: Historische Vereniging Nijeveen + source_url: https://historischeverenigingnijeveen.nl/ + retrieved_on: "2025-11-29T12:28:00Z" + xpath: /[document][1]/html[1]/body[1]/div[6]/div[1]/table[3]/tbody[1]/tr[1]/td[1]/p[6] + html_file: web/0021/historischeverenigingnijeveen.nl/rendered.html + xpath_match_score: 1.0 +``` + +### Example - WRONG (Fabricated - Must Be Removed) + +```yaml +web_enrichment: + claims: + - claim_type: full_name + claim_value: Historische Vereniging Nijeveen + confidence: 0.95 # ← NO! This is meaningless without XPath +``` + +--- + +## Forbidden: Confidence Scores Without XPath + +**NEVER use arbitrary confidence scores for web-extracted claims.** + +Confidence scores like `0.95`, `0.90`, `0.85` are meaningless because: +1. There is NO methodology defining what these numbers mean +2. They cannot be verified or reproduced +3. They give false impression of rigor +4. They mask the fact that claims may be fabricated + +If a value appears in the HTML → `xpath_match_score: 1.0` +If a value does NOT appear in the HTML → **REMOVE THE CLAIM** + +--- + +## Website Archiving Workflow + +### Step 1: Archive the Website + +Use Playwright to archive websites with JavaScript rendering: + +```bash +python scripts/fetch_website_playwright.py <entry_number> <url> + +# Example: +python scripts/fetch_website_playwright.py 0021 https://historischeverenigingnijeveen.nl/ +``` + +This creates: +``` +data/nde/enriched/entries/web/{entry_number}/{domain}/ +├── index.html # Raw HTML as received +├── rendered.html # HTML after JS execution +├── content.md # Markdown conversion +└── metadata.yaml # XPath extractions for provenance +``` + +### Step 2: Add XPath Provenance to Claims + +Run the XPath migration script: + +```bash +python scripts/add_xpath_provenance.py + +# Or for specific entries: +python scripts/add_xpath_provenance.py --entries 0021,0022,0023 +``` + +This script: +1. Reads each entry's `web_enrichment.claims` +2. Searches archived HTML for each claim value +3. Adds `xpath` + `html_file` if found +4. **REMOVES claims that cannot be verified** (stores in `removed_unverified_claims`) + +### Step 3: Audit Removed Claims + +Check `removed_unverified_claims` in each entry file: + +```yaml +removed_unverified_claims: + - claim_type: phone + claim_value: "+31 6 12345678" + reason: "Value not found in archived HTML - likely fabricated" + removed_on: "2025-11-29T14:30:00Z" +``` + +These claims were NOT in the HTML and should NOT be restored without proper sourcing. + +--- + +## Claim Types and Expected Sources + +| Claim Type | Expected Source | Notes | +|------------|-----------------|-------| +| `full_name` | Page title, heading, logo text | Usually in `<h1>`, `<title>`, or prominent `<div>` | +| `description` | Meta description, about text | Check `<meta name="description">` first | +| `email` | Contact page, footer | Often in `<a href="mailto:...">` | +| `phone` | Contact page, footer | May need normalization | +| `address` | Contact page, footer | Check for structured data too | +| `social_media` | Footer, contact page | Links to social platforms | +| `opening_hours` | Contact/visit page | May be in structured data | + +--- + +## XPath Matching Strategy + +The `add_xpath_provenance.py` script uses this matching strategy: + +1. **Exact match**: Claim value appears exactly in element text +2. **Normalized match**: After whitespace normalization +3. **Substring match**: Claim value is substring of element text (score < 1.0) + +Priority order for matching: +1. `rendered.html` (after JS execution) - preferred +2. `index.html` (raw HTML) - fallback + +--- + +## Integration with LinkML Schema + +The `WebClaim` class in the LinkML schema requires: + +```yaml +# schemas/20251121/linkml/modules/classes/WebClaim.yaml +WebClaim: + slots: + - source_url # Required + - retrieved_on # Required (timestamp) + - xpath # Required for claims + - html_archive_path # Path to archived HTML +``` + +--- + +## Rules for AI Agents + +### When Extracting Claims from Websites + +1. **ALWAYS archive the website first** using Playwright +2. **ALWAYS extract claims with XPath provenance** using the archived HTML +3. **NEVER invent or infer claims** not present in the HTML +4. **NEVER use confidence scores** without XPath backing + +### When Processing Existing Claims + +1. **Verify each claim** against archived HTML +2. **Add XPath provenance** to verified claims +3. **REMOVE fabricated claims** that cannot be verified +4. **Document removed claims** in `removed_unverified_claims` + +### When Reviewing Data Quality + +1. Claims with `xpath` + `html_file` = **VERIFIED** +2. Claims with only `confidence` = **SUSPECT** (migrate or remove) +3. Claims in `removed_unverified_claims` = **FABRICATED** (do not restore) + +--- + +## Scripts Reference + +| Script | Purpose | +|--------|---------| +| `scripts/fetch_website_playwright.py` | Archive website with Playwright | +| `scripts/add_xpath_provenance.py` | Add XPath to claims, remove fabricated | +| `scripts/batch_fetch_websites.py` | Batch archive multiple entries | + +--- + +## Version History + +- **2025-11-29**: Initial version - established XPath provenance requirement +- Replaced confidence scores with verifiable XPath pointers +- Established policy of removing fabricated claims diff --git a/schemas/20251121/uml/mermaid/records_lifecycle_20251209_131205.mmd b/schemas/20251121/uml/mermaid/records_lifecycle_20251209_131205.mmd new file mode 100644 index 0000000000..e17c6b8291 --- /dev/null +++ b/schemas/20251121/uml/mermaid/records_lifecycle_20251209_131205.mmd @@ -0,0 +1,124 @@ +```mermaid +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#e3f2fd', 'primaryTextColor': '#1565c0', 'primaryBorderColor': '#1565c0', 'lineColor': '#424242', 'secondaryColor': '#fff3e0', 'tertiaryColor': '#e8f5e9'}}}%% +graph TB + %% Heritage Custodian Records Lifecycle + %% Generated: 2025-12-09 13:12:05 + %% Three-tier model: Administration → Archive → Collection + %% For bronhouder.nl visual representation + + subgraph "PHASE 1: ACTIVE RECORDS" + direction TB + ADMIN["<b>CustodianAdministration</b><br/><i>rico:RecordResource</i><br/>━━━━━━━━━━━━━━━━━<br/>ACTIVE records in daily use<br/>• Current correspondence<br/>• Personnel files<br/>• Financial records<br/>• Digital files on shared drives<br/>• Email, databases<br/>━━━━━━━━━━━━━━━━━<br/>Managed by: Business units<br/>Retention: Per schedule"] + + style ADMIN fill:#c8e6c9,stroke:#2e7d32,stroke-width:3px + end + + subgraph "PHASE 2: INACTIVE ARCHIVES" + direction TB + ARCHIVE["<b>CustodianArchive</b><br/><i>rico:RecordSet</i><br/>━━━━━━━━━━━━━━━━━<br/>INACTIVE records awaiting processing<br/>• Transferred from administration<br/>• In BACKLOG (may wait DECADES)<br/>• Basic accession-level description<br/>• NOT searchable by researchers<br/>• Tracked in CMS for inventory<br/>━━━━━━━━━━━━━━━━━<br/>Managed by: Archives staff<br/>Status: ArchiveProcessingStatusEnum"] + + style ARCHIVE fill:#fff9c4,stroke:#f9a825,stroke-width:3px + end + + subgraph "PHASE 3: HERITAGE COLLECTION" + direction TB + COLLECTION["<b>CustodianCollection</b><br/><i>crm:E78_Curated_Holding</i><br/>━━━━━━━━━━━━━━━━━<br/>PROCESSED heritage collection<br/>• Full finding aid available<br/>• Searchable by researchers<br/>• Arranged per archival standards<br/>• Integrated into public collection<br/>• Managed as cultural heritage<br/>━━━━━━━━━━━━━━━━━<br/>Managed by: Curators<br/>Access: Public/Restricted"] + + style COLLECTION fill:#bbdefb,stroke:#1565c0,stroke-width:3px + end + + %% Transitions between phases + ADMIN -->|"<b>TRANSFER</b><br/>Retention period ends<br/>Records closed<br/>prov:wasGeneratedBy"| ARCHIVE + ARCHIVE -->|"<b>PROCESSING</b><br/>Appraisal complete<br/>Finding aid created<br/>prov:hadDerivation"| COLLECTION + + %% Lifecycle Type Classifications (SKOS) + subgraph "Archive Lifecycle Types (Wikidata)" + direction LR + TYPE_CURRENT["<b>CurrentArchive</b><br/>Q3621648<br/><i>Active phase</i>"] + TYPE_DEPOSIT["<b>DepositArchive</b><br/>Q244904<br/><i>Semi-current phase</i>"] + TYPE_HISTORICAL["<b>HistoricalArchive</b><br/>Q3621673<br/><i>Archival phase</i>"] + + style TYPE_CURRENT fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,stroke-dasharray: 5 5 + style TYPE_DEPOSIT fill:#fff9c4,stroke:#f9a825,stroke-width:2px,stroke-dasharray: 5 5 + style TYPE_HISTORICAL fill:#bbdefb,stroke:#1565c0,stroke-width:2px,stroke-dasharray: 5 5 + end + + %% Type classifications link to phases + TYPE_CURRENT -.->|skos:narrower| ADMIN + TYPE_DEPOSIT -.->|skos:narrower| ARCHIVE + TYPE_HISTORICAL -.->|skos:narrower| COLLECTION + + %% Timeline example + subgraph "Example: Ministry Records" + direction TB + EX_TIMELINE["<b>Temporal Reality</b><br/>━━━━━━━━━━━━━━━━━━━━━━━━<br/>2010-2020: Created (Administration)<br/>2021: Transferred to Archives<br/>2021-2024: In processing backlog<br/>2024: Archivist assigned<br/>2025: Finding aid complete<br/>2025: Available to researchers<br/>━━━━━━━━━━━━━━━━━━━━━━━━<br/><i>Total processing time: 4 years</i><br/><i>(Large archives: 30-50 years)</i>"] + + style EX_TIMELINE fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px + end + + %% Processing Status Enum connection + subgraph "Processing Status" + direction LR + STATUS["<b>ArchiveProcessingStatusEnum</b><br/>UNPROCESSED → IN_APPRAISAL →<br/>IN_ARRANGEMENT → IN_DESCRIPTION →<br/>PROCESSED_PENDING_TRANSFER →<br/>TRANSFERRED_TO_COLLECTION"] + + style STATUS fill:#e0e0e0,stroke:#616161,stroke-width:1px + end + + ARCHIVE -.->|processing_status| STATUS + + %% Custodian Hub connection + subgraph "Central Entity" + HUB["<b>Custodian</b><br/>(Hub Entity)<br/>All records belong to<br/>one heritage institution"] + + style HUB fill:#ffeb3b,stroke:#f57f17,stroke-width:4px + end + + ADMIN -.->|"refers_to_custodian<br/>crm:P46i"| HUB + ARCHIVE -.->|"refers_to_custodian<br/>crm:P46i"| HUB + COLLECTION -.->|"refers_to_custodian<br/>crm:P46i"| HUB + + %% Legend + subgraph "Legend" + direction LR + L1["Solid arrow = Data flow/transition"] + L2["Dashed arrow = Reference/classification"] + L3["Green = Active | Yellow = Processing | Blue = Archived"] + end +``` + +--- + +## Records Lifecycle Model + +This diagram shows the three-tier model for heritage custodian records: + +### Phase 1: CustodianAdministration (Active Records) +- **Ontology**: `rico:RecordResource` +- **Status**: In daily operational use +- **Managed by**: Business units (not archives staff) +- **Examples**: Current correspondence, personnel files, financial records + +### Phase 2: CustodianArchive (Inactive Archives) +- **Ontology**: `rico:RecordSet` +- **Status**: Awaiting archival processing (often DECADES) +- **Managed by**: Archives staff +- **Tracking**: `ArchiveProcessingStatusEnum` +- **Key insight**: NOT yet searchable by researchers + +### Phase 3: CustodianCollection (Heritage Collection) +- **Ontology**: `crm:E78_Curated_Holding` +- **Status**: Fully processed, public/restricted access +- **Managed by**: Curators +- **Features**: Full finding aid, integrated into heritage collection + +### Key Relationships +- `prov:wasGeneratedBy`: Links archive to transfer activity +- `prov:hadDerivation`: Links archive to resulting collection +- `crm:P46i_forms_part_of`: All phases belong to same Custodian hub + +### Lifecycle Type Classifications (SKOS/Wikidata) +- **CurrentArchive** (Q3621648): Active records phase TYPE +- **DepositArchive** (Q244904): Semi-current/intermediate phase TYPE +- **HistoricalArchive** (Q3621673): Permanent archival phase TYPE + +These are TYPE classifications (skos:Concept) that can be applied to INSTANCE records via `lifecycle_phase_type` slot using `skos:broaderTransitive`. diff --git a/scripts/geocode_missing_from_geonames.py b/scripts/geocode_missing_from_geonames.py new file mode 100644 index 0000000000..16464db2ec --- /dev/null +++ b/scripts/geocode_missing_from_geonames.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +Geocode Missing Coordinates from GeoNames Database + +This script geocodes custodian files that are missing coordinates using the local +GeoNames database. It's much faster than API-based geocoding (no rate limits). + +Features: +- Uses local GeoNames SQLite database for instant lookups +- Fuzzy matching for city names +- Updates files in-place preserving YAML structure +- Batch processing with progress tracking +- Safe updates (additive only, preserves existing data) + +Usage: + python scripts/geocode_missing_from_geonames.py --dry-run + python scripts/geocode_missing_from_geonames.py --country JP --limit 100 + python scripts/geocode_missing_from_geonames.py --all +""" + +import argparse +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional +import unicodedata + +from ruamel.yaml import YAML + +# Setup ruamel.yaml for round-trip preservation +yaml = YAML() +yaml.preserve_quotes = True +yaml.width = 120 + +# Configuration +CUSTODIAN_DIR = Path("/Users/kempersc/apps/glam/data/custodian") +GEONAMES_DB = Path("/Users/kempersc/apps/glam/data/reference/geonames.db") + + +def normalize_city_name(name: Optional[str]) -> str: + """Normalize city name for matching.""" + if not name: + return "" + # NFD decomposition and remove accents + normalized = unicodedata.normalize('NFD', name) + ascii_name = ''.join(c for c in normalized if unicodedata.category(c) != 'Mn') + result = ascii_name.lower().strip() + + # Remove common Japanese administrative suffixes + # These are romanized forms of 市 (shi/city), 区 (ku/ward), 町 (machi/town), etc. + jp_suffixes = [' shi', '-shi', ' ku', '-ku', ' machi', '-machi', ' cho', '-cho', + ' ken', '-ken', ' gun', '-gun', ' son', '-son', ' mura', '-mura'] + for suffix in jp_suffixes: + if result.endswith(suffix): + result = result[:-len(suffix)] + break + + return result + + +class GeoNamesLookup: + """Fast city coordinate lookup from GeoNames database.""" + + def __init__(self, db_path: Path): + self.conn = sqlite3.connect(db_path) + self.conn.row_factory = sqlite3.Row + + def lookup_city(self, city: str, country_code: str, region: str = None) -> Optional[dict]: + """ + Look up city coordinates in GeoNames database. + + Returns dict with latitude, longitude, geonames_id, etc. or None if not found. + """ + if not city or not country_code: + return None + + # Normalize inputs + city_norm = normalize_city_name(city) + country_code = country_code.upper() + + # Try exact match first (case-insensitive) + cursor = self.conn.execute(""" + SELECT geonames_id, name, ascii_name, latitude, longitude, + admin1_code, admin1_name, feature_code, population + FROM cities + WHERE country_code = ? + AND (LOWER(name) = ? OR LOWER(ascii_name) = ?) + ORDER BY population DESC + LIMIT 1 + """, (country_code, city_norm or "", city_norm or "")) + + row = cursor.fetchone() + if row: + return self._row_to_dict(row) + + # Try with original city name (for non-ASCII) + cursor = self.conn.execute(""" + SELECT geonames_id, name, ascii_name, latitude, longitude, + admin1_code, admin1_name, feature_code, population + FROM cities + WHERE country_code = ? + AND (name = ? OR ascii_name = ?) + ORDER BY population DESC + LIMIT 1 + """, (country_code, city, city)) + + row = cursor.fetchone() + if row: + return self._row_to_dict(row) + + # Try partial match (city name contains or is contained in) + cursor = self.conn.execute(""" + SELECT geonames_id, name, ascii_name, latitude, longitude, + admin1_code, admin1_name, feature_code, population + FROM cities + WHERE country_code = ? + AND (LOWER(name) LIKE ? OR LOWER(ascii_name) LIKE ?) + ORDER BY population DESC + LIMIT 1 + """, (country_code, f"%{city_norm}%", f"%{city_norm}%")) + + row = cursor.fetchone() + if row: + return self._row_to_dict(row) + + return None + + def _row_to_dict(self, row) -> dict: + """Convert database row to dictionary.""" + return { + 'geonames_id': row['geonames_id'], + 'geonames_name': row['name'], + 'latitude': row['latitude'], + 'longitude': row['longitude'], + 'admin1_code': row['admin1_code'], + 'admin1_name': row['admin1_name'], + 'feature_code': row['feature_code'], + 'population': row['population'] + } + + def close(self): + self.conn.close() + + +def extract_city_country(data: dict) -> tuple[Optional[str], Optional[str]]: + """Extract city and country from custodian data.""" + city = None + country = None + + # Try location block first + loc = data.get('location', {}) + if loc: + city = loc.get('city') + country = loc.get('country') + + # Try ghcid.location_resolution + if not city: + ghcid_loc = data.get('ghcid', {}).get('location_resolution', {}) + if ghcid_loc: + city = (ghcid_loc.get('city_name') or + ghcid_loc.get('city_label') or + ghcid_loc.get('geonames_name') or + ghcid_loc.get('google_maps_locality')) + if not country: + country = ghcid_loc.get('country_code') + + # Try original_entry.locations + if not city: + orig_locs = data.get('original_entry', {}).get('locations', []) + if orig_locs and len(orig_locs) > 0: + city = orig_locs[0].get('city') + country = orig_locs[0].get('country') + + # Try to infer country from GHCID + if not country: + ghcid = data.get('ghcid', {}).get('ghcid_current', '') + if ghcid and len(ghcid) >= 2: + country = ghcid[:2] + + return city, country + + +def geocode_file(filepath: Path, geonames: GeoNamesLookup, dry_run: bool = False) -> dict: + """ + Geocode a single custodian file using GeoNames. + + Returns: + Dictionary with results: + - success: bool + - geocoded: bool (True if coordinates were added) + - already_has_coords: bool + - error: str or None + """ + result = { + 'success': False, + 'geocoded': False, + 'already_has_coords': False, + 'city': None, + 'country': None, + 'error': None + } + + try: + with open(filepath, 'r', encoding='utf-8') as f: + data = yaml.load(f) + + if not isinstance(data, dict): + result['error'] = "Invalid YAML structure" + return result + + # Check if already has coordinates + loc = data.get('location', {}) + if loc.get('latitude') is not None and loc.get('longitude') is not None: + result['success'] = True + result['already_has_coords'] = True + return result + + # Extract city and country + city, country = extract_city_country(data) + result['city'] = city + result['country'] = country + + if not city or not country: + result['error'] = f"Missing city ({city}) or country ({country})" + result['success'] = True # Not an error, just no data to geocode + return result + + # Look up in GeoNames + geo_result = geonames.lookup_city(city, country) + + if not geo_result: + result['error'] = f"City not found in GeoNames: {city}, {country}" + result['success'] = True # Not a fatal error + return result + + # Update location block with coordinates + if 'location' not in data: + data['location'] = {} + + data['location']['latitude'] = geo_result['latitude'] + data['location']['longitude'] = geo_result['longitude'] + data['location']['coordinate_provenance'] = { + 'source_type': 'GEONAMES_LOCAL', + 'source_path': 'data/reference/geonames.db', + 'entity_id': geo_result['geonames_id'], + 'original_timestamp': datetime.now(timezone.utc).isoformat() + } + + # Add geonames reference if not present + if not data['location'].get('geonames_id'): + data['location']['geonames_id'] = geo_result['geonames_id'] + if not data['location'].get('geonames_name'): + data['location']['geonames_name'] = geo_result['geonames_name'] + if not data['location'].get('feature_code'): + data['location']['feature_code'] = geo_result['feature_code'] + + # Update normalization timestamp + data['location']['normalization_timestamp'] = datetime.now(timezone.utc).isoformat() + + if not dry_run: + with open(filepath, 'w', encoding='utf-8') as f: + yaml.dump(data, f) + + result['success'] = True + result['geocoded'] = True + return result + + except Exception as e: + result['error'] = str(e) + return result + + +def main(): + parser = argparse.ArgumentParser( + description="Geocode missing coordinates using GeoNames database" + ) + parser.add_argument('--dry-run', action='store_true', help="Preview without writing") + parser.add_argument('--country', type=str, help="Only process specific country code (e.g., JP)") + parser.add_argument('--limit', type=int, default=0, help="Limit number of files to process") + parser.add_argument('--all', action='store_true', help="Process all files (no limit)") + parser.add_argument('--verbose', action='store_true', help="Show detailed output") + + args = parser.parse_args() + + if args.dry_run: + print("DRY RUN - No files will be modified\n") + + # Initialize GeoNames lookup + if not GEONAMES_DB.exists(): + print(f"Error: GeoNames database not found at {GEONAMES_DB}") + return 1 + + geonames = GeoNamesLookup(GEONAMES_DB) + + # Get list of files to process + if args.country: + pattern = f"{args.country.upper()}-*.yaml" + files = sorted(CUSTODIAN_DIR.glob(pattern)) + print(f"Processing {args.country.upper()} files: {len(files)} found") + else: + files = sorted(CUSTODIAN_DIR.glob("*.yaml")) + print(f"Processing all files: {len(files)} found") + + if args.limit and not args.all: + files = files[:args.limit] + print(f"Limited to first {args.limit} files") + + # Statistics + stats = { + 'total': len(files), + 'geocoded': 0, + 'already_has_coords': 0, + 'no_city_data': 0, + 'not_found': 0, + 'errors': 0, + 'by_country': {} + } + + errors = [] + not_found = [] + + for i, filepath in enumerate(files): + result = geocode_file(filepath, geonames, dry_run=args.dry_run) + + # Extract country from filename + country = filepath.name[:2] + if country not in stats['by_country']: + stats['by_country'][country] = {'geocoded': 0, 'not_found': 0} + + if result['geocoded']: + stats['geocoded'] += 1 + stats['by_country'][country]['geocoded'] += 1 + elif result['already_has_coords']: + stats['already_has_coords'] += 1 + elif result['error'] and 'Missing city' in result['error']: + stats['no_city_data'] += 1 + elif result['error'] and 'not found in GeoNames' in result['error']: + stats['not_found'] += 1 + stats['by_country'][country]['not_found'] += 1 + if len(not_found) < 100: + not_found.append((filepath.name, result['city'], result['country'])) + elif result['error']: + stats['errors'] += 1 + if len(errors) < 20: + errors.append((filepath.name, result['error'])) + + if args.verbose: + status = "GEOCODED" if result['geocoded'] else "SKIP" if result['already_has_coords'] else "FAIL" + print(f"[{i+1}/{len(files)}] {filepath.name}: {status}") + elif (i + 1) % 1000 == 0: + print(f"Processed {i+1}/{len(files)} files... (geocoded: {stats['geocoded']})") + + # Print summary + print("\n" + "=" * 60) + print("GEOCODING SUMMARY") + print("=" * 60) + print(f"Total files processed: {stats['total']}") + print(f"Already had coordinates: {stats['already_has_coords']}") + print(f"Successfully geocoded: {stats['geocoded']}") + print(f"No city data available: {stats['no_city_data']}") + print(f"City not found in GeoNames: {stats['not_found']}") + print(f"Errors: {stats['errors']}") + + if stats['by_country']: + print("\nResults by country:") + for country, data in sorted(stats['by_country'].items(), key=lambda x: -x[1]['geocoded']): + if data['geocoded'] > 0 or data['not_found'] > 0: + print(f" {country}: geocoded={data['geocoded']}, not_found={data['not_found']}") + + if not_found: + print(f"\nFirst {len(not_found)} cities not found:") + for filename, city, country in not_found[:20]: + print(f" {filename}: {city}, {country}") + + if errors: + print(f"\nFirst {len(errors)} errors:") + for filename, error in errors: + print(f" {filename}: {error}") + + if args.dry_run: + print("\n(DRY RUN - No files were modified)") + + geonames.close() + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/scripts/load_custodians_to_ducklake_v3.py b/scripts/load_custodians_to_ducklake_v3.py index ff4e6b7978..95ea18d62b 100644 --- a/scripts/load_custodians_to_ducklake_v3.py +++ b/scripts/load_custodians_to_ducklake_v3.py @@ -399,9 +399,26 @@ def extract_top_level_fields(data: dict) -> dict: # Extract wikidata inception/dissolution wd = data.get("wikidata_enrichment", {}) if wd: - record["wikidata_inception"] = wd.get("wikidata_inception", "") or wd.get("wikidata_founded", "") - if wd.get("wikidata_dissolution") and not record["dissolution_date"]: - record["dissolution_date"] = wd.get("wikidata_dissolution", "") or wd.get("wikidata_dissolved", "") + # Try multiple paths for inception date + wikidata_inception = ( + wd.get("wikidata_inception", "") or + wd.get("wikidata_founded", "") or + wd.get("wikidata_temporal", {}).get("inception", "") + ) + record["wikidata_inception"] = wikidata_inception + + # Use wikidata_inception as founding_date fallback + if wikidata_inception and not record["founding_date"]: + record["founding_date"] = wikidata_inception + + # Try multiple paths for dissolution date + wikidata_dissolution = ( + wd.get("wikidata_dissolution", "") or + wd.get("wikidata_dissolved", "") + ) + if wikidata_dissolution and not record["dissolution_date"]: + record["dissolution_date"] = wikidata_dissolution + record["wikidata_enrichment_json"] = json.dumps(wd, ensure_ascii=False, default=str) # Extract service_area