My scripts:

  • rendered html versions: Rpubs/thomas-weissensteiner
  • .rmd files with executable code chunks: www.github.com/thomas-weissensteiner/portfolio/tree/main/

About myself: www.linkedin.com/in/ThomasWs-Mopfair



1. Challenge and Visualisation


This visualisation was a fun project initiated by “Wonderful Wednesdays”, a monthly webinar organised by the Visualisation Special Interest Group of Statisticians in the Pharmaceutical Industry (PSI). The topic was creating a “seasonal plot” using AI tools, and sharing the lessons learnt.

# Running this chunk requires R 4.0.0+ which can use raw string literal syntax r"(...)" with htmltools::HTML()

library(htmltools)

html_content <- r"(
      
<!-- Beginning of HTML code -->

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Christmas Traditions Knowledge Graph</title>
    <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
    <script src="https://unpkg.com/react-force-graph-2d@1.25.4/dist/react-force-graph-2d.min.js"></script>
    <style>
    * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
            background-color: #111827;
            color: white;
            overflow: hidden;
        }
        #root {
            width: 100vw;
            height: 100vh;
            display: flex;
        }
        .control-panel {
            width: 320px;
            background-color: #1f2937;
            padding: 20px;
            overflow-y: auto;
            border-right: 1px solid #374151;
        }
        .graph-container {
            flex: 1;
            position: relative;
            overflow: hidden;
        }
        h1 {
            font-size: 24px;
            margin-bottom: 24px;
            color: #ef4444;
        }
        .section {
            margin-bottom: 24px;
        }
        .section-title {
            font-size: 14px;
            font-weight: 600;
            margin-bottom: 12px;
            display: flex;
            align-items: center;
            gap: 8px;
        }
        input[type="text"] {
            width: 100%;
            padding: 8px 12px;
            background-color: #374151;
            border: 1px solid #4b5563;
            border-radius: 4px;
            color: white;
            font-size: 14px;
        }
        input[type="text"]:focus {
            outline: none;
            border-color: #3b82f6;
        }
        .search-hint {
            font-size: 11px;
            color: #6b7280;
            margin-top: 4px;
            font-style: italic;
        }
        .category-item {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 8px;
            cursor: pointer;
            border-radius: 4px;
            transition: background-color 0.2s;
            margin-bottom: 4px;
        }
        .category-item:hover {
            background-color: #374151;
        }
        .category-header {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 8px;
            margin-bottom: 8px;
            font-size: 11px;
            font-weight: 600;
            color: #9ca3af;
            text-transform: uppercase;
            letter-spacing: 0.5px;
            border-bottom: 1px solid #374151;
        }
        .category-left {
            display: flex;
            align-items: center;
            gap: 8px;
            flex: 1;
        }
        .category-item input {
            width: 16px;
            height: 16px;
            cursor: pointer;
        }
        .color-dot {
            width: 12px;
            height: 12px;
            border-radius: 50%;
            flex-shrink: 0;
        }
        .category-name {
            font-size: 14px;
            text-transform: capitalize;
            flex: 1;
        }
        .category-stat {
            font-size: 11px;
            color: #9ca3af;
            white-space: nowrap;
        }
        input[type="range"] {
            width: 100%;
            height: 6px;
            background: #374151;
            outline: none;
            border-radius: 3px;
        }
        input[type="range"]::-webkit-slider-thumb {
            -webkit-appearance: none;
            appearance: none;
            width: 16px;
            height: 16px;
            background: #3b82f6;
            cursor: pointer;
            border-radius: 50%;
        }
        input[type="range"]::-moz-range-thumb {
            width: 16px;
            height: 16px;
            background: #3b82f6;
            cursor: pointer;
            border-radius: 50%;
            border: none;
        }
        .slider-label {
            font-size: 14px;
            font-weight: 600;
            margin-bottom: 8px;
            display: block;
        }
        button {
            width: 100%;
            padding: 10px;
            background-color: #374151;
            border: none;
            border-radius: 4px;
            color: white;
            font-size: 13px;
            cursor: pointer;
            transition: background-color 0.2s;
        }
        button:hover {
            background-color: #4b5563;
        }
        .legend {
            margin-top: 24px;
            padding-top: 24px;
            border-top: 1px solid #374151;
        }
        .legend-title {
            font-size: 14px;
            font-weight: 600;
            margin-bottom: 8px;
        }
        .legend-text {
            font-size: 12px;
            color: #9ca3af;
            margin-bottom: 8px;
        }
        .legend-item {
            display: flex;
            align-items: center;
            gap: 8px;
            font-size: 12px;
            color: #9ca3af;
            margin-bottom: 6px;
        }
        .legend-line {
            width: 30px;
            height: 2px;
            background: #999;
        }
        .legend-line.dashed {
            background: repeating-linear-gradient(
                to right,
                #999 0,
                #999 4px,
                transparent 4px,
                transparent 8px
            );
        }
        .stats {
            margin-top: 16px;
            padding: 12px;
            background-color: #374151;
            border-radius: 4px;
            font-size: 12px;
        }
        .stat-row {
            display: flex;
            justify-content: space-between;
            margin-bottom: 4px;
        }
        .stat-value {
            font-weight: 600;
        }
        .hover-info {
            position: absolute;
            top: 16px;
            right: 16px;
            background-color: #1f2937;
            border: 1px solid #374151;
            border-radius: 8px;
            padding: 16px;
            max-width: 320px;
            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
            z-index: 10;
        }
        .hover-info h3 {
            font-size: 18px;
            margin-bottom: 4px;
        }
        .hover-info .category-tag {
            font-size: 12px;
            color: #9ca3af;
            text-transform: capitalize;
            margin-bottom: 8px;
        }
        .hover-info .emoji-icon {
            font-size: 64px;
            text-align: center;
            margin: 12px 0;
        }
        .hover-info p {
            font-size: 14px;
            color: #d1d5db;
        }
        .category-badges {
            display: flex;
            gap: 4px;
            flex-wrap: wrap;
            margin-top: 4px;
        }
        .category-badge {
            padding: 2px 6px;
            border-radius: 3px;
            font-size: 10px;
            text-transform: capitalize;
        }

        /* Shooting stars animation */
        @keyframes shootingStar {
            0% {
                transform: translate(0, 0) rotate(-45deg);
                opacity: 1;
            }
            100% {
                transform: translate(-400px, 400px) rotate(-45deg);
                opacity: 0;
            }
        }
        
        .shooting-star {
            position: absolute;
            width: 2px;
            height: 2px;
            background: white;
            border-radius: 50%;
            box-shadow: 0 0 4px 2px rgba(255, 255, 255, 0.8);
            animation: shootingStar 1.5s linear;
            pointer-events: none;
        }
        
        .shooting-star::after {
            content: '';
            position: absolute;
            top: 0;
            left: 0;
            width: 80px;
            height: 2px;
            background: linear-gradient(90deg, rgba(255,255,255,0.8), transparent);
            transform-origin: left;
        }
        
        .attribution {
            position: absolute;
            bottom: 16px;
            right: 16px;
            font-size: 8px;
            color: #9ca3af;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
            pointer-events: none;
            z-index: 5;
        }
    </style>
</head>
<body>
    <div id="root"></div>

    <script>
        const { useState, useRef, useCallback, useMemo, useEffect } = React;
        const ForceGraph2D = window.ForceGraph2D;

        const categoryColors = {
            tradition: '#e74c3c',
            place: '#27ae60',
            person: '#3498db',
            religion: '#ffe066',
            symbol: '#9b59b6',
            food: '#e67e22',
            commercialisation: '#b0b0b0'
        };

        const relationshipColors = {
            'originated in': '#27ae60',
            'created by': '#3498db',
            'influenced': '#9b59b6',
            'derived from': '#e74c3c',
            'popularised': '#e67e22',
            'associated with': '#ffe066'
        };

        // Helper function to get categories as array (supports both single and multiple)
        function getCategories(node) {
            if (node.categories) return node.categories;
            if (node.category) return [node.category];
            return [];
        }

        // Function to draw a multi-colored star for multi-category nodes
        function drawMultiColorStar(ctx, cx, cy, categories, outerRadius, innerRadius) {
            const numCategories = categories.length;
            const pointsPerCategory = numCategories === 1 ? 5 : (numCategories === 2 ? 4 : 3);
            const totalPoints = pointsPerCategory * numCategories;
            
            let rot = Math.PI / 2 * 3;
            const step = Math.PI / totalPoints;
            
            // Draw each spike with appropriate color
            for (let i = 0; i < totalPoints; i++) {
                const categoryIndex = Math.floor(i / pointsPerCategory);
                const color = categoryColors[categories[categoryIndex]];
                
                ctx.fillStyle = color;
                ctx.beginPath();
                
                // Center point
                ctx.moveTo(cx, cy);
                
                // Outer point
                let x = cx + Math.cos(rot) * outerRadius;
                let y = cy + Math.sin(rot) * outerRadius;
                ctx.lineTo(x, y);
                rot += step;
                
                // Inner point
                x = cx + Math.cos(rot) * innerRadius;
                y = cy + Math.sin(rot) * innerRadius;
                ctx.lineTo(x, y);
                rot += step;
                
                ctx.closePath();
                ctx.fill();
            }
        }

        const graphData = {
            nodes: [
                // Traditions
                { id: 'christmas-tree', name: 'Christmas Tree', categories: ['tradition', 'symbol'], description: 'Decorated evergreen tree tradition', url: 'https://en.wikipedia.org/wiki/Christmas_tree', img: '🎄' },
                { id: 'gift-giving', name: 'Gift Giving', category: 'tradition', description: 'Exchange of presents', url: 'https://en.wikipedia.org/wiki/Christmas_gift', img: '🎁' },
                { id: 'caroling', name: 'Caroling', category: 'tradition', description: 'Singing Christmas songs door-to-door', url: 'https://en.wikipedia.org/wiki/Christmas_carol', img: '🎵' },
                { id: 'advent-calendar', name: 'Advent Calendar', category: 'tradition', description: 'Countdown to Christmas', url: 'https://en.wikipedia.org/wiki/Advent_calendar', img: '🕯️' },
                { id: 'nativity-scene', name: 'Nativity Scene', categories: ['tradition', 'religion'], description: 'Display of Jesus birth', url: 'https://en.wikipedia.org/wiki/Nativity_of_Jesus', img: '🛖' },
                { id: 'yule-log', name: 'Yule Log', category: 'tradition', description: 'Burning of large log', url: 'https://en.wikipedia.org/wiki/Yule_log', img: '🪵' },
                { id: 'santa-visits', name: 'Santa Claus Visits', category: 'tradition', description: 'Santa bringing gifts on Christmas Eve', url: 'https://en.wikipedia.org/wiki/Santa_Claus', img: '🎅' },
                { id: 'stockings', name: 'Christmas Stockings', category: 'tradition', description: 'Hanging stockings for gifts', url: 'https://en.wikipedia.org/wiki/Christmas_stocking', img: '🧦' },
                { id: 'mistletoe', name: 'Mistletoe Kissing', categories: ['tradition', 'symbol'], description: 'Kissing under mistletoe tradition', url: 'https://en.wikipedia.org/wiki/Mistletoe', img: '💋' },
                { id: 'christmas-cards', name: 'Christmas Cards', category: 'tradition', description: 'Sending greeting cards', url: 'https://en.wikipedia.org/wiki/Christmas_card', img: '💌' },
                { id: 'wassail', name: 'Wassail', category: 'tradition', description: 'Traditional drinking ritual and caroling', url: 'https://en.wikipedia.org/wiki/Wassail', img: '🍺' },
                { id: 'mumming', name: 'Mumming', category: 'tradition', description: 'Folk play performed at Christmas', url: 'https://en.wikipedia.org/wiki/Mummers%27_play', img: '👹' },
                { id: 'christmas-pantomime', name: 'Christmas Pantomime', category: 'tradition', description: 'British theatrical entertainment tradition', url: 'https://en.wikipedia.org/wiki/Pantomime', img: '🎭' },
                { id: 'christmas-party', name: 'Christmas Party', category: 'tradition', description: 'Social gathering celebrating Christmas', url: 'https://www.wusf.org/arts-culture/2024-12-23/when-christians-joined-party-surprising-origin-christmas', img: '🎉' },
                { id: 'christkind', name: 'Christkind', category: 'tradition', description: 'Christ Child gift-bringer tradition', url: 'https://en.wikipedia.org/wiki/Christkind', img: '👼' },
                
                // Places
                { id: 'germany', name: 'Germany', category: 'place', description: 'Origin of many Christmas traditions', url: 'https://en.wikipedia.org/wiki/Christmas_in_Germany', img: 'https://s.w.org/images/core/emoji/16.0.1/svg/1f1e9-1f1ea.svg' },
                { id: 'england', name: 'United Kingdom', category: 'place', description: 'Victorian Christmas traditions', url: 'https://en.wikipedia.org/wiki/Christmas_in_England', img: 'https://s.w.org/images/core/emoji/16.0.1/svg/1f1ec-1f1e7.svg' },
                { id: 'netherlands', name: 'Netherlands', category: 'place', description: 'Sinterklaas tradition', url: 'https://en.wikipedia.org/wiki/Sinterklaas', img: 'https://s.w.org/images/core/emoji/16.0.1/svg/1f1f3-1f1f1.svg' },
                { id: 'scandinavia', name: 'Scandinavia', category: 'place', description: 'Nordic Yule traditions', url: 'https://en.wikipedia.org/wiki/Yule', img: '❄️' },
                { id: 'italy', name: 'Italy', category: 'place', description: 'Nativity scene origin', url: 'https://en.wikipedia.org/wiki/Christmas_in_Italy', img: 'https://s.w.org/images/core/emoji/16.0.1/svg/1f1ee-1f1f9.svg' },
                { id: 'usa', name: 'United States', category: 'place', description: 'Modern commercialized Christmas', url: 'https://en.wikipedia.org/wiki/Christmas_in_the_United_States', img: 'https://s.w.org/images/core/emoji/16.0.1/svg/1f1fa-1f1f8.svg' },
                
                // People
                { id: 'st-nicholas', name: 'Nicholas of Myra', category: 'person', description: '4th century bishop, basis for Santa', url: 'https://en.wikipedia.org/wiki/Saint_Nicholas', img: '♗' },
                { id: 'martin-luther', name: 'Martin Luther', category: 'person', description: 'Popularized Christmas tree', url: 'https://3arf.org/en/martin-luther-the-reformation-and-christmas/', img: '⛪' },
                { id: 'queen-victoria', name: 'Queen Victoria', category: 'person', description: 'Popularized Christmas tree in England', url: 'https://en.wikipedia.org/wiki/Queen_Victoria', img: '👸' },
                { id: 'charles-dickens', name: 'Charles Dickens', category: 'person', description: 'Wrote A Christmas Carol', url: 'https://en.wikipedia.org/wiki/Charles_Dickens', img: '🧔🏼' },
                { id: 'clement-moore', name: 'Clement Clarke Moore', category: 'person', description: 'Wrote A Visit from St. Nicholas', url: 'https://en.wikipedia.org/wiki/Clement_Clarke_Moore', img: '✍️' },
                { id: 'francis-assisi', name: 'Francis of Assisi', category: 'person', description: 'Created first nativity scene', url: 'https://en.wikipedia.org/wiki/Francis_of_Assisi', img: '🙏' },
                { id: 'luke', name: 'Luke the Evangelist', category: 'person', description: 'Gospel writer who recorded nativity story', url: 'https://en.wikipedia.org/wiki/Luke_the_Evangelist', img: '📜' },
                { id: 'matthew', name: 'Matthew the Apostle', category: 'person', description: 'Gospel writer who recorded nativity story', url: 'https://en.wikipedia.org/wiki/Matthew_the_Apostle', img: '📜' },
                { id: 'thomas-nast', name: 'Thomas Nast', category: 'person', description: 'Cartoonist who shaped modern Santa image', url: 'https://en.wikipedia.org/wiki/Thomas_Nast', img: '🎨' },
                { id: 'jesus', name: 'Jesus of Nazareth', category: 'person', description: 'Central figure of Christianity', url: 'https://en.wikipedia.org/wiki/Jesus', img: '🍇' },
                { id: 'constantine', name: 'Constantine I', category: 'person', description: 'Roman emperor who legalized Christianity', url: 'https://en.wikipedia.org/wiki/Constantine_the_Great', img: '🤴🏼' },
                
                // Religious/Historical
                { id: 'saturnalia', name: 'Saturnalia', category: 'religion', description: 'Ancient Roman winter festival', url: 'https://en.wikipedia.org/wiki/Saturnalia', img: '🪐' },
                { id: 'paganism', name: 'Paganism & Winter Solstice', category: 'religion', description: 'Pre-Christian traditions and winter solstice celebrations', url: 'https://en.wikipedia.org/wiki/Paganism', img: '🧙' },
                { id: 'christianity', name: 'Christianity', category: 'religion', description: 'Religious foundation', url: 'https://en.wikipedia.org/wiki/Christmas', img: '✝️' },
                { id: 'biblical-accounts', name: 'Biblical Accounts', category: 'religion', description: 'Gospel narratives of the nativity', url: 'https://en.wikipedia.org/wiki/Nativity_of_Jesus', img: '📖' },
                
                // Symbols
                { id: 'holly', name: 'Holly', category: 'symbol', description: 'Evergreen plant symbol', url: 'https://en.wikipedia.org/wiki/Holly', img: '🌿' },
                { id: 'candy-cane', name: 'Candy Cane', categories: ['symbol', 'food'], description: 'Striped peppermint stick', url: 'https://en.wikipedia.org/wiki/Candy_cane', img: '🍬' },
                { id: 'wreaths', name: 'Wreaths', categories: ['symbol', 'tradition'], description: 'Circular evergreen decoration', url: 'https://en.wikipedia.org/wiki/Wreath', img: '🎀' },
                
                // Commercialisation
                { id: 'santa-suit', name: 'Santa Suit', categories: ['commercialisation', 'symbol'], description: 'Red and white costume popularized by advertising', url: 'https://en.wikipedia.org/wiki/Santa_suit', img: '👢' },
                { id: 'christmas-market', name: 'Christmas Market', category: 'commercialisation', description: 'Traditional seasonal market', url: 'https://en.wikipedia.org/wiki/Christmas_market', img: '🌰' },
                { id: 'christmas-shopping', name: 'Christmas Shopping', category: 'commercialisation', description: 'Seasonal consumer activity', url: 'https://en.wikipedia.org/wiki/Economics_of_Christmas', img: '🛍️' },
                
                // Foods
                { id: 'gingerbread', name: 'Gingerbread', category: 'food', description: 'Spiced cookie tradition', url: 'https://en.wikipedia.org/wiki/Gingerbread', img: '🍪' },
                { id: 'mince-pies', name: 'Mince Pies', category: 'food', description: 'British sweet pastry', url: 'https://en.wikipedia.org/wiki/Mince_pie', img: '🥧' },
                { id: 'christmas-pudding', name: 'Christmas Pudding', category: 'food', description: 'British steamed dessert', url: 'https://en.wikipedia.org/wiki/Christmas_pudding', img: '🍮' },
                { id: 'stollen', name: 'Stollen', category: 'food', description: 'German fruit bread', url: 'https://en.wikipedia.org/wiki/Stollen', img: '🍞' }
            ],
            links: [
                // Paganism & Winter Solstice connections (8 edges)
                { source: 'paganism', target: 'christmas-tree', relationship: 'derived from' },
                { source: 'paganism', target: 'yule-log', relationship: 'derived from' },
                { source: 'paganism', target: 'holly', relationship: 'associated with' },
                { source: 'paganism', target: 'mistletoe', relationship: 'associated with' },
                { source: 'paganism', target: 'wreaths', relationship: 'derived from' },
                { source: 'paganism', target: 'wassail', relationship: 'derived from' },
                { source: 'paganism', target: 'mumming', relationship: 'derived from' },
                { source: 'saturnalia', target: 'paganism', relationship: 'associated with' },
                
                // Germany connections (7 edges)
                { source: 'germany', target: 'christmas-tree', relationship: 'originated in' },
                { source: 'germany', target: 'santa-visits', relationship: 'originated in' },
                { source: 'germany', target: 'gingerbread', relationship: 'originated in' },
                { source: 'germany', target: 'stollen', relationship: 'originated in' },
                { source: 'germany', target: 'candy-cane', relationship: 'originated in' },
                { source: 'germany', target: 'advent-calendar', relationship: 'originated in' },
                { source: 'germany', target: 'christmas-market', relationship: 'originated in' },
                
                // England/United Kingdom connections (7 edges)
                { source: 'england', target: 'christmas-cards', relationship: 'originated in' },
                { source: 'england', target: 'caroling', relationship: 'originated in' },
                { source: 'england', target: 'christmas-pantomime', relationship: 'originated in' },
                { source: 'england', target: 'mistletoe', relationship: 'originated in' },
                { source: 'england', target: 'mince-pies', relationship: 'originated in' },
                { source: 'england', target: 'christmas-pudding', relationship: 'originated in' },
                { source: 'england', target: 'wassail', relationship: 'originated in' },
                
                // Santa Claus Visits connections (6 edges)
                { source: 'st-nicholas', target: 'santa-visits', relationship: 'derived from' },
                { source: 'clement-moore', target: 'santa-visits', relationship: 'influenced' },
                { source: 'netherlands', target: 'santa-visits', relationship: 'originated in' },
                { source: 'santa-visits', target: 'stockings', relationship: 'associated with' },
                { source: 'santa-visits', target: 'santa-suit', relationship: 'associated with' },
                
                // Gift Giving connections (5 edges)
                { source: 'saturnalia', target: 'gift-giving', relationship: 'influenced' },
                { source: 'st-nicholas', target: 'gift-giving', relationship: 'influenced' },
                { source: 'christianity', target: 'gift-giving', relationship: 'influenced' },
                { source: 'gift-giving', target: 'christkind', relationship: 'associated with' },
                { source: 'gift-giving', target: 'christmas-shopping', relationship: 'associated with' },
                
                // Nativity Scene connections (5 edges)
                { source: 'francis-assisi', target: 'nativity-scene', relationship: 'influenced' },
                { source: 'italy', target: 'nativity-scene', relationship: 'originated in' },
                { source: 'christianity', target: 'nativity-scene', relationship: 'associated with' },
                { source: 'jesus', target: 'nativity-scene', relationship: 'associated with' },
                { source: 'biblical-accounts', target: 'nativity-scene', relationship: 'derived from' },
                
                // Christmas Tree connections (4 edges)
                { source: 'martin-luther', target: 'christmas-tree', relationship: 'popularised' },
                { source: 'queen-victoria', target: 'christmas-tree', relationship: 'popularised' },
                
                // Saturnalia connections (4 edges)
                { source: 'christmas-pantomime', target: 'saturnalia', relationship: 'influenced' },
                { source: 'saturnalia', target: 'christmas-party', relationship: 'influenced' },
                
                // Christianity connections (4 edges)
                { source: 'jesus', target: 'christianity', relationship: 'created by' },
                { source: 'christianity', target: 'advent-calendar', relationship: 'associated with' },
                
                // Christmas Pantomime connections (3 edges)
                { source: 'mumming', target: 'christmas-pantomime', relationship: 'derived from' },
                
                // Santa Suit connections (3 edges)
                { source: 'usa', target: 'santa-suit', relationship: 'originated in' },
                { source: 'thomas-nast', target: 'santa-suit', relationship: 'created by' },
                
                // Biblical Accounts connections (3 edges)
                { source: 'luke', target: 'biblical-accounts', relationship: 'created by' },
                { source: 'matthew', target: 'biblical-accounts', relationship: 'created by' },
                
                // Other connections (2 edges each)
                { source: 'martin-luther', target: 'christkind', relationship: 'created by' },
                { source: 'queen-victoria', target: 'christmas-cards', relationship: 'popularised' },
                { source: 'charles-dickens', target: 'caroling', relationship: 'influenced' },
                { source: 'scandinavia', target: 'yule-log', relationship: 'originated in' },
                { source: 'scandinavia', target: 'mistletoe', relationship: 'originated in' },
                { source: 'constantine', target: 'christmas-party', relationship: 'popularised' },
                { source: 'christmas-market', target: 'christmas-shopping', relationship: 'associated with' }
            ]
        };

        function App() {
            const graphRef = useRef();
            const graphContainerRef = useRef();
            
            // Image cache for flag SVGs
            const imageCache = useRef({});
            
            const [selectedCategories, setSelectedCategories] = useState({
                tradition: true,
                place: true,
                person: true,
                religion: true,
                symbol: true,
                food: true,
                commercialisation: true
            });
            const [selectedRelationships, setSelectedRelationships] = useState({
                'originated in': false,
                'created by': false,
                'derived from': false,
                'influenced': false,
                'popularised': false,
                'associated with': false
            });
            const [linkDistance, setLinkDistance] = useState(100);
            const [highlightNodes, setHighlightNodes] = useState(new Set());
            const [highlightLinks, setHighlightLinks] = useState(new Set());
            const [hoverNode, setHoverNode] = useState(null);
            const [dimensions, setDimensions] = useState({
                width: window.innerWidth - 320,
                height: window.innerHeight
            });
            
            // Handle window resize
            useEffect(() => {
                const handleResize = () => {
                    setDimensions({
                        width: window.innerWidth - 320,
                        height: window.innerHeight
                    });
                };
                window.addEventListener('resize', handleResize);
                return () => window.removeEventListener('resize', handleResize);
            }, []);
            

            // Pre-defined node positions - must be declared before use
            const nodePositions = useMemo(() => ({
                "christmas-tree": { x: 39.15, y: -141.61 },
                "gift-giving": { x: -131.18, y: 46.84 },
                "caroling": { x: 303.75, y: -136.58 },
                "advent-calendar": { x: 1.95, y: -81.35 },
                "nativity-scene": { x: -239.04, y: 158.26 },
                "yule-log": { x: 220.76, y: 94.87 },
                "santa-visits": { x: -271.45, y: 84.03 },
                "stockings": { x: -454.19, y: 59.43 },
                "mistletoe": { x: 131.91, y: -71.41 },
                "christmas-cards": { x: 190.3, y: -197.59 },
                "wassail": { x: 126.22, y: -122.84 },
                "mumming": { x: 262.63, y: 34.79 },
                "christmas-pantomime": { x: 415.28, y: 78.48 },
                "christmas-party": { x: 120.93, y: 133.5 },
                "germany": { x: -38, y: -161.09 },
                "england": { x: 200.12, y: -107.2 },
                "netherlands": { x: -549.8, y: 104.82 },
                "scandinavia": { x: 339.01, y: 169.24 },
                "italy": { x: -396.74, y: 324.49 },
                "usa": { x: -629.08, y: 233.92 },
                "st-nicholas": { x: -202.76, y: 66.3 },
                "martin-luther": { x: 34.49, y: -319.85 },
                "queen-victoria": { x: 175.26, y: -319.67 },
                "charles-dickens": { x: 425.45, y: -169.64 },
                "clement-moore": { x: -611.34, y: 166.45 },
                "francis-assisi": { x: -304.78, y: 278.4 },
                "luke": { x: -578.79, y: 338.6 },
                "matthew": { x: -492.87, y: 352.68 },
                "thomas-nast": { x: -618.11, y: 296.43 },
                "jesus": { x: -131.28, y: 173.79 },
                "constantine": { x: 172.62, y: 233.88 },
                "saturnalia": { x: 72.37, y: 38.45 },
                "paganism": { x: 39.83, y: -24.08 },
                "christianity": { x: -89.83, y: 93.31 },
                "biblical-accounts": { x: -355.88, y: 222.66 },
                "holly": { x: -52.89, y: 210.95 },
                "candy-cane": { x: -213.94, y: -207.6 },
                "wreaths": { x: 42.3, y: 238.07 },
                "santa-suit": { x: -423.83, y: 174.3 },
                "christmas-market": { x: -83.31, y: -58.85 },
                "christmas-shopping": { x: -108.74, y: -1.07 },
                "christkind": { x: -247.57, y: -11.7 },
                "gingerbread": { x: -249.07, y: -105.31 },
                "mince-pies": { x: 334.63, y: -278.46 },
                "christmas-pudding": { x: 459.61, y: -42.54 },
                "stollen": { x: -117.14, y: -292.45 }
            }), []);

            // Preload flag images
            useEffect(() => {
                graphData.nodes.forEach(node => {
                    if (node.img && node.img.startsWith('http') && !imageCache.current[node.img]) {
                        const img = new Image();
                        img.onload = () => {
                            imageCache.current[node.img] = img;
                        };
                        img.src = node.img;
                    }
                });
            }, []);

            // Shooting stars effect
            useEffect(() => {
                const createShootingStar = () => {
                    if (!graphContainerRef.current) return;
                    
                    const star = document.createElement('div');
                    star.className = 'shooting-star';
                    star.style.left = Math.random() * 100 + '%';
                    star.style.top = Math.random() * 50 + '%';
                    
                    graphContainerRef.current.appendChild(star);
                    
                    setTimeout(() => {
                        if (star.parentNode) {
                            star.parentNode.removeChild(star);
                        }
                    }, 1500);
                };
                
                // Create a shooting star every 3-8 seconds
                const scheduleNext = () => {
                    const delay = 3000 + Math.random() * 5000;
                    setTimeout(() => {
                        createShootingStar();
                        scheduleNext();
                    }, delay);
                };
                
                scheduleNext();
            }, []);

            // Build adjacency map once for all connections
            const adjacencyMap = useMemo(() => {
                const map = new Map();
                graphData.links.forEach(link => {
                    const sourceId = link.source.id || link.source;
                    const targetId = link.target.id || link.target;
                    
                    if (!map.has(sourceId)) map.set(sourceId, []);
                    if (!map.has(targetId)) map.set(targetId, []);
                    
                    map.get(sourceId).push(targetId);
                    map.get(targetId).push(sourceId);
                });
                return map;
            }, []); // Only calculate once since graphData is static

            // Function to find indirect connections through hidden nodes
            const findIndirectConnections = useCallback((visibleNodes, allLinks) => {
                const indirectLinks = [];
                const visibleNodeIds = new Set(visibleNodes.map(n => n.id));
                
                // For each pair of visible nodes, check if they're connected through hidden nodes
                for (let i = 0; i < visibleNodes.length; i++) {
                    for (let j = i + 1; j < visibleNodes.length; j++) {
                        const node1 = visibleNodes[i].id;
                        const node2 = visibleNodes[j].id;
                        
                        // Check if they're already directly connected
                        const directlyConnected = allLinks.some(link => {
                            const sourceId = link.source.id || link.source;
                            const targetId = link.target.id || link.target;
                            return (sourceId === node1 && targetId === node2) ||
                                   (sourceId === node2 && targetId === node1);
                        });
                        
                        if (!directlyConnected) {
                            // Check for indirect connection through hidden nodes
                            const neighbors1 = adjacencyMap.get(node1) || [];
                            const neighbors2 = adjacencyMap.get(node2) || [];
                            
                            // Find hidden nodes that connect both
                            const hiddenBridges = neighbors1.filter(n => 
                                !visibleNodeIds.has(n) && neighbors2.includes(n)
                            );
                            
                            if (hiddenBridges.length > 0) {
                                indirectLinks.push({
                                    source: node1,
                                    target: node2,
                                    indirect: true
                                });
                            }
                        }
                    }
                }
                
                return indirectLinks;
            }, [adjacencyMap]);

            const getFilteredData = useCallback(() => {
                const filteredNodes = graphData.nodes.filter(node => {
                    const nodeCategories = getCategories(node);
                    // Show node if ANY of its categories is selected
                    const categoryMatch = nodeCategories.some(cat => selectedCategories[cat]);
                    return categoryMatch;
                });

                const nodeIds = new Set(filteredNodes.map(n => n.id));
                // Show all links between visible nodes - do not filter by relationship type
                const directLinks = graphData.links.filter(link => {
                    const hasNodes = nodeIds.has(link.source.id || link.source) && 
                                    nodeIds.has(link.target.id || link.target);
                    return hasNodes;
                });
                
                // Find indirect connections
                const indirectLinks = findIndirectConnections(filteredNodes, graphData.links);

                return { 
                    nodes: filteredNodes, 
                    links: [...directLinks, ...indirectLinks]
                };
            }, [selectedCategories, findIndirectConnections]);

            const filteredData = useMemo(() => getFilteredData(), [getFilteredData]);

            // Apply saved positions after graph loads
            useEffect(() => {
                const hasPositions = Object.keys(nodePositions).length > 0;
                if (hasPositions && filteredData.nodes.length > 0) {
                    filteredData.nodes.forEach(node => {
                        if (nodePositions[node.id]) {
                            node.x = nodePositions[node.id].x;
                            node.y = nodePositions[node.id].y;
                            node.fx = nodePositions[node.id].x;
                            node.fy = nodePositions[node.id].y;
                        }
                    });
                    
                    // Zoom to fit after a short delay
                    if (graphRef.current) {
                        setTimeout(() => {
                            graphRef.current?.zoomToFit(400, 50);
                        }, 300);
                    }
                }
            }, [filteredData, nodePositions]);

            // Calculate degree centrality
            const nodeMetrics = useMemo(() => {
                const metrics = new Map();
                
                // Count connections for each node
                filteredData.nodes.forEach(node => {
                    metrics.set(node.id, { degree: 0, node: node });
                });
                
                filteredData.links.forEach(link => {
                    const sourceId = link.source.id || link.source;
                    const targetId = link.target.id || link.target;
                    
                    if (metrics.has(sourceId)) {
                        metrics.get(sourceId).degree++;
                    }
                    if (metrics.has(targetId)) {
                        metrics.get(targetId).degree++;
                    }
                });
                
                return metrics;
            }, [filteredData]);

            // Calculate initial category order and stats from full graph (only once)
            const { initialCategoryOrder, initialCategoryStats } = useMemo(() => {
                const allNodes = graphData.nodes;
                const allLinks = graphData.links;
                
                // Build metrics for all nodes
                const allMetrics = new Map();
                allNodes.forEach(node => {
                    allMetrics.set(node.id, { degree: 0, node: node });
                });
                
                allLinks.forEach(link => {
                    const sourceId = link.source.id || link.source;
                    const targetId = link.target.id || link.target;
                    
                    if (allMetrics.has(sourceId)) {
                        allMetrics.get(sourceId).degree++;
                    }
                    if (allMetrics.has(targetId)) {
                        allMetrics.get(targetId).degree++;
                    }
                });
                
                // Calculate avg degree for each category
                const stats = {};
                Object.keys(categoryColors).forEach(category => {
                    const categoryNodes = Array.from(allMetrics.values())
                        .filter(m => getCategories(m.node).includes(category));
                    
                    if (categoryNodes.length > 0) {
                        const avgDegree = categoryNodes.reduce((sum, m) => sum + m.degree, 0) / categoryNodes.length;
                        stats[category] = {
                            count: categoryNodes.length,
                            avgDegree: avgDegree.toFixed(1)
                        };
                    } else {
                        stats[category] = {
                            count: 0,
                            avgDegree: '0.0'
                        };
                    }
                });
                
                // Sort by avg degree (descending)
                const order = Object.keys(categoryColors)
                    .sort((a, b) => parseFloat(stats[b].avgDegree) - parseFloat(stats[a].avgDegree));
                
                return { initialCategoryOrder: order, initialCategoryStats: stats };
            }, []);

            // Calculate initial relationship order and stats from full graph (only once)
            const { initialRelationshipOrder, initialRelationshipStats } = useMemo(() => {
                const stats = {};
                
                Object.keys(relationshipColors).forEach(rel => {
                    const count = graphData.links.filter(link => 
                        link.relationship === rel
                    ).length;
                    stats[rel] = count;
                });
                
                // Sort by count (descending)
                const order = Object.keys(relationshipColors)
                    .sort((a, b) => stats[b] - stats[a]);
                
                return { initialRelationshipOrder: order, initialRelationshipStats: stats };
            }, []);

            // Use initial stats (fixed values based on complete graph)
            const categoryStats = initialCategoryStats;
            const relationshipStats = initialRelationshipStats;

            const toggleCategory = (category) => {
                setSelectedCategories(prev => ({
                    ...prev,
                    [category]: !prev[category]
                }));
            };

            const selectAllCategories = () => {
                const allSelected = {};
                Object.keys(categoryColors).forEach(cat => {
                    allSelected[cat] = true;
                });
                setSelectedCategories(allSelected);
            };

            const deselectAllCategories = () => {
                const allDeselected = {};
                Object.keys(categoryColors).forEach(cat => {
                    allDeselected[cat] = false;
                });
                setSelectedCategories(allDeselected);
            };

            const toggleRelationship = (relationship) => {
                setSelectedRelationships(prev => ({
                    ...prev,
                    [relationship]: !prev[relationship]
                }));
            };

            const selectAllRelationships = () => {
                const allSelected = {};
                Object.keys(relationshipColors).forEach(rel => {
                    allSelected[rel] = true;
                });
                setSelectedRelationships(allSelected);
            };

            const deselectAllRelationships = () => {
                const allDeselected = {};
                Object.keys(relationshipColors).forEach(rel => {
                    allDeselected[rel] = false;
                });
                setSelectedRelationships(allDeselected);
            };

            const handleNodeHover = (node) => {
                setHoverNode(node);
                if (node) {
                    const neighbors = new Set();
                    const links = new Set();
                    
                    filteredData.links.forEach(link => {
                        const source = link.source.id || link.source;
                        const target = link.target.id || link.target;
                        if (source === node.id) {
                            neighbors.add(target);
                            links.add(link);
                        }
                        if (target === node.id) {
                            neighbors.add(source);
                            links.add(link);
                        }
                    });
                    neighbors.add(node.id);
                    setHighlightNodes(neighbors);
                    setHighlightLinks(links);
                } else {
                    setHighlightNodes(new Set());
                    setHighlightLinks(new Set());
                }
            };

            const handleNodeClick = (node) => {
                if (node.url) {
                    window.open(node.url, '_blank');
                }
            };

            const handleNodeDragEnd = (node) => {
                // Fix node in its new position permanently
                node.fx = node.x;
                node.fy = node.y;
            };

            const handleEngineStop = () => {
            };

            useEffect(() => {
                if (!graphRef.current) return;
                
                const graph = graphRef.current;  
                const linkForce = graph.d3Force('link');
                
                if (linkForce) {
                    if (linkDistance !== 100 && filteredData.nodes.length > 0) {
                        filteredData.nodes.forEach(node => {
                            node.fx = null;
                            node.fy = null;
                        });
                    }
                    
                    linkForce.distance(linkDistance);
                    graph.d3ReheatSimulation();
                }
            }, [linkDistance, filteredData]);

            return React.createElement('div', { style: { display: 'flex', width: '100vw', height: '100vh' } },
                // Control Panel
                React.createElement('div', { className: 'control-panel' },
                    React.createElement('h1', null, '🎄 Christmas Customs'),
                    
                    React.createElement('div', { className: 'section' },
                        React.createElement('div', { className: 'section-title' }, '🎨 Categories'),
                        React.createElement('div', { className: 'category-header' },
                            React.createElement('div', { className: 'category-left' },
                                React.createElement('span', { style: { width: '16px' } }),
                                React.createElement('span', { style: { width: '12px' } }),
                                React.createElement('span', null, 'Name')
                            ),
                            React.createElement('span', { className: 'category-stat' }, 
                                'Avg connections'
                            )
                        ),
                        React.createElement('div', { style: { display: 'flex', flexDirection: 'column' } },
                            initialCategoryOrder.map(category =>
                                React.createElement('label', { 
                                    key: category, 
                                    className: 'category-item',
                                    onClick: () => toggleCategory(category),
                                    style: { opacity: selectedCategories[category] ? 1 : 0.5 }
                                },
                                    React.createElement('div', { className: 'category-left' },
                                        React.createElement('input', {
                                            type: 'checkbox',
                                            checked: selectedCategories[category],
                                            onChange: () => toggleCategory(category),
                                            onClick: (e) => e.stopPropagation()
                                        }),
                                        React.createElement('span', { 
                                            className: 'color-dot',
                                            style: { backgroundColor: categoryColors[category] }
                                        }),
                                        React.createElement('span', { className: 'category-name' }, category)
                                    ),
                                    React.createElement('span', { className: 'category-stat' }, 
                                        categoryStats[category] ? `${categoryStats[category].avgDegree}` : '0.0'
                                    )
                                )
                            )
                        ),
                        React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '12px' } },
                            React.createElement('button', {
                                onClick: selectAllCategories,
                                style: { fontSize: '11px', padding: '6px' }
                            }, 'Select All'),
                            React.createElement('button', {
                                onClick: deselectAllCategories,
                                style: { fontSize: '11px', padding: '6px' }
                            }, 'Deselect All')
                        )
                    ),

                    React.createElement('div', { className: 'section' },
                        React.createElement('div', { className: 'section-title' }, '🔗 Relationships'),
                        React.createElement('div', { className: 'category-header' },
                            React.createElement('div', { className: 'category-left' },
                                React.createElement('span', { style: { width: '16px' } }),
                                React.createElement('span', { style: { width: '12px' } }),
                                React.createElement('span', null, 'Type')
                            ),
                            React.createElement('span', { className: 'category-stat' }, 'Count')
                        ),
                        React.createElement('div', { style: { display: 'flex', flexDirection: 'column' } },
                            initialRelationshipOrder.map(relationship =>
                                React.createElement('label', { 
                                    key: relationship, 
                                    className: 'category-item',
                                    onClick: () => toggleRelationship(relationship),
                                    style: { opacity: selectedRelationships[relationship] ? 1 : 0.5 }
                                },
                                    React.createElement('div', { className: 'category-left' },
                                        React.createElement('input', {
                                            type: 'checkbox',
                                            checked: selectedRelationships[relationship],
                                            onChange: () => toggleRelationship(relationship),
                                            onClick: (e) => e.stopPropagation()
                                        }),
                                        React.createElement('span', { 
                                            className: 'color-dot',
                                            style: { backgroundColor: relationshipColors[relationship] }
                                        }),
                                        React.createElement('span', { className: 'category-name' }, relationship)
                                    ),
                                    React.createElement('span', { className: 'category-stat' }, 
                                        relationshipStats[relationship] || 0
                                    )
                                )
                            )
                        ),
                        React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '12px' } },
                            React.createElement('button', {
                                onClick: selectAllRelationships,
                                style: { fontSize: '11px', padding: '6px' }
                            }, 'Select All'),
                            React.createElement('button', {
                                onClick: deselectAllRelationships,
                                style: { fontSize: '11px', padding: '6px' }
                            }, 'Deselect All')
                        )
                    ),

                    React.createElement('div', { className: 'section' },
                        React.createElement('label', { className: 'slider-label' }, 
                            `Link Distance: ${linkDistance}`
                        ),
                        React.createElement('input', {
                            type: 'range',
                            min: '50',
                            max: '300',
                            value: linkDistance,
                            onChange: (e) => setLinkDistance(Number(e.target.value))
                        })
                    ),

                    React.createElement('div', { className: 'section' },
                        React.createElement('div', { className: 'section-title' }, '⚙️ Graph Controls'),
                        React.createElement('button', {
                            onClick: () => graphRef.current?.zoomToFit(400),
                            style: { marginBottom: '8px' }
                        }, 'Fit to Screen'),
                        React.createElement('button', {
                            onClick: () => {
                                // Reset to star shape by reapplying fixed positions
                                filteredData.nodes.forEach(node => {
                                    if (nodePositions[node.id]) {
                                        node.x = nodePositions[node.id].x;
                                        node.y = nodePositions[node.id].y;
                                        node.fx = nodePositions[node.id].x;
                                        node.fy = nodePositions[node.id].y;
                                    }
                                });
                                setLinkDistance(100);
                                if (graphRef.current) {
                                    graphRef.current.zoomToFit(400, 50);
                                }
                            },
                            style: { marginBottom: '8px' }
                        }, 'Reset to Star Shape'),
                        React.createElement('p', {
                            style: {
                                fontSize: '11px',
                                color: '#9ca3af',
                                marginTop: '8px',
                                fontStyle: 'italic'
                            }
                        }, 'Use the Link Distance slider, or drag individual nodes to change the appearance of the graph')
                    ),

                    React.createElement('div', { className: 'legend' },
                        React.createElement('div', { className: 'legend-title' }, 'Legend'),
                        React.createElement('p', { className: 'legend-text' }, 
                            'Node size = number of connections'
                        ),
                        React.createElement('p', { className: 'legend-text' }, 
                            'Multi-colored stars = multiple categories'
                        ),
                        React.createElement('p', { className: 'legend-text' }, 
                            'Click nodes to visit Wikipedia'
                        ),
                        React.createElement('p', { className: 'legend-text' }, 
                            'Hover to see connections'
                        ),
                        React.createElement('p', { className: 'legend-text' }, 
                            'Tick boxes to filter categories and highlight relationships'
                        ),
                        React.createElement('div', { className: 'legend-item' },
                            React.createElement('div', { className: 'legend-line' }),
                            React.createElement('span', null, 'Direct connection')
                        ),
                        React.createElement('div', { className: 'legend-item' },
                            React.createElement('div', { className: 'legend-line dashed' }),
                            React.createElement('span', null, '2nd degree connections')
                        )
                    ),

                    React.createElement('div', { className: 'stats' },
                        React.createElement('div', { className: 'stat-row' },
                            React.createElement('span', null, 'Nodes:'),
                            React.createElement('span', { className: 'stat-value' }, filteredData.nodes.length)
                        ),
                        React.createElement('div', { className: 'stat-row' },
                            React.createElement('span', null, 'Connections:'),
                            React.createElement('span', { className: 'stat-value' }, filteredData.links.length)
                        )
                    )
                ),

                // Graph Container
                React.createElement('div', { 
                    ref: graphContainerRef,
                    className: 'graph-container' 
                },
                    React.createElement(ForceGraph2D, {
                        ref: graphRef,
                        graphData: filteredData,
                        width: dimensions.width,
                        height: dimensions.height,
                        nodeLabel: node => {
                            const categories = getCategories(node);
                            const categoryText = categories.map(c => c.charAt(0).toUpperCase() + c.slice(1)).join(', ');
                            return `
                                <div style="background: rgba(0,0,0,0.9); padding: 8px; border-radius: 4px; max-width: 200px;">
                                    <strong style="color: ${categoryColors[categories[0]]}">${node.name}</strong>
                                    <br/>
                                    <span style="font-size: 11px; color: #999;">${categoryText}</span>
                                    <br/>
                                    <span style="font-size: 12px; color: #ccc;">${node.description}</span>
                                    <br/>
                                    <span style="font-size: 11px; color: #999;">Click to learn more</span>
                                </div>
                            `;
                        },
                        nodeCanvasObject: (node, ctx, globalScale) => {
                            const label = node.name;
                            const fontSize = 12/globalScale;
                            ctx.font = `${fontSize}px Sans-Serif`;
                            
                            const isHighlighted = highlightNodes.has(node.id);
                            const categories = getCategories(node);
                            
                            // Scale node size based on degree centrality - increased to 2x size
                            const metrics = nodeMetrics.get(node.id);
                            const degree = metrics ? metrics.degree : 0;
                            const baseSize = (4 + Math.min(degree, 10) * 0.6) * 2;
                            const nodeSize = isHighlighted ? baseSize + 2 : baseSize;
                            const innerRadius = nodeSize * 0.4;
                            
                            // Draw multi-colored star
                            drawMultiColorStar(ctx, node.x, node.y, categories, nodeSize, innerRadius);
                            
                            // Draw label
                            ctx.textAlign = 'center';
                            ctx.textBaseline = 'middle';
                            ctx.fillStyle = isHighlighted ? '#ffffff' : '#cccccc';
                            ctx.fillText(label, node.x, node.y + nodeSize + fontSize);
                            
                            // Draw highlight ring
                            if (isHighlighted) {
                                ctx.strokeStyle = categoryColors[categories[0]];
                                ctx.lineWidth = 2/globalScale;
                                ctx.beginPath();
                                ctx.arc(node.x, node.y, nodeSize + 2, 0, 2 * Math.PI);
                                ctx.stroke();
                            }
                        },
                        linkColor: link => {
                            const isHighlighted = highlightLinks.has(link);
                            if (isHighlighted) {
                                return '#ffffff';
                            }
                            if (link.indirect) {
                                return 'rgba(150,150,150,0.3)';
                            }
                            // Apply color if this relationship type is selected
                            if (link.relationship && selectedRelationships[link.relationship]) {
                                const color = relationshipColors[link.relationship];
                                // Convert hex to rgba with 0.5 alpha
                                const r = parseInt(color.slice(1,3), 16);
                                const g = parseInt(color.slice(3,5), 16);
                                const b = parseInt(color.slice(5,7), 16);
                                return `rgba(${r},${g},${b},0.5)`;
                            }
                            return 'rgba(150,150,150,0.3)';
                        },
                        linkWidth: link => highlightLinks.has(link) ? 2 : 1,
                        linkLineDash: link => link.indirect ? [5, 5] : null,
                        linkDirectionalArrowLength: link => link.indirect ? 0 : 14,
                        linkDirectionalArrowRelPos: 0.5,
                        linkDirectionalArrowColor: link => {
                            const isHighlighted = highlightLinks.has(link);
                            if (isHighlighted) {
                                return '#ffffff';
                            }
                            if (link.indirect) {
                                return 'transparent';
                            }
                            // Apply color if this relationship type is selected
                            if (link.relationship && selectedRelationships[link.relationship]) {
                                const color = relationshipColors[link.relationship];
                                // Convert hex to rgba with 0.5 alpha
                                const r = parseInt(color.slice(1,3), 16);
                                const g = parseInt(color.slice(3,5), 16);
                                const b = parseInt(color.slice(5,7), 16);
                                return `rgba(${r},${g},${b},0.5)`;
                            }
                            return 'rgba(150,150,150,0.3)';
                        },
                        linkDirectionalParticles: link => highlightLinks.has(link) ? 2 : 0,
                        linkDirectionalParticleWidth: 2,
                        d3VelocityDecay: 0.3,
                        d3AlphaDecay: 0.02,
                        linkDistance: linkDistance,
                        onNodeHover: handleNodeHover,
                        onNodeClick: handleNodeClick,
                        onNodeDragEnd: handleNodeDragEnd,
                        backgroundColor: '#111827',
                        cooldownTicks: 100,
                        onEngineStop: handleEngineStop,
                        enableNodeDrag: true
                    }),
                    
                    hoverNode && React.createElement('div', { className: 'hover-info' },
                        React.createElement('h3', { style: { color: categoryColors[getCategories(hoverNode)[0]] } }, 
                            hoverNode.name
                        ),
                        React.createElement('div', { className: 'category-badges' },
                            getCategories(hoverNode).map(cat =>
                                React.createElement('span', {
                                    key: cat,
                                    className: 'category-badge',
                                    style: { 
                                        backgroundColor: categoryColors[cat],
                                        color: '#fff'
                                    }
                                }, cat)
                            )
                        ),
                        hoverNode.img && hoverNode.img.startsWith('http') 
                            ? React.createElement('img', { 
                                src: hoverNode.img, 
                                className: 'emoji-icon',
                                style: { width: '64px', height: '64px', margin: '12px auto', display: 'block' }
                              })
                            : React.createElement('div', { className: 'emoji-icon' }, hoverNode.img),
                        React.createElement('p', null, hoverNode.description),
                        React.createElement('p', { 
                            style: { 
                                fontSize: '12px', 
                                color: '#9ca3af', 
                                marginTop: '8px',
                                borderTop: '1px solid #374151',
                                paddingTop: '8px'
                            } 
                        }, 
                            `${nodeMetrics.get(hoverNode.id)?.degree || 0} connections`
                        )
                    ),
                    
                    React.createElement('div', { className: 'attribution' }, '© Thomas Weissensteiner 2025')
                )
            );
        }

        ReactDOM.render(React.createElement(App), document.getElementById('root'));
    </script>
</body>
</html>
          
<!-- End of HTML code -->
          
)"

browsable(
  tags$iframe(
    srcdoc = html_content,
    style = "width: 175%; height: 1000px"
  )
)



2. Setup for AI-assisted generation of the knowledge graph


The graph was created with the help of Claude Sonnet 4.5 (claude.ai). Claude`s web-artifacts-builder and canvas-design skills were not enabled (https://claude.ai/settings/capabilities)

Prompt for initiating the project:

“I would like to generate an interactive knowledge graph. The theme is popular Christmas traditions around the world, their historical origins, relationships with each other, and other contemporary customs.
Interactive features could include

  • links to online information when the cursor hovers of a node

  • options to customise the graph: selecting node categories (persons, places, traditions, etc.), changing edge distance, etc.

Please

  • add suggestions for content and visualisation (bullet point list, minimal details)

  • sketch a strategy for implementation, including programming language(s) and packages best suited for the task, and a bullet-point outline of the essential tasks involved in building the knowledge graph”

Prompt for restarting the conversation in a new chat (start with project description, as above):

“Attached is a draft version. Please check the code for robustness and efficiency. Remove any parts that are obsolete and suggest improvements”

3. Insights for AI-assisted programming


3.1. General principles


  1. AI-ASSISTED GENERATION OF AN INTERACTIVE VISUALISATION REQUIRES NO KNOWLEDGE OF SPECIFIC PROGRAMMING LANGUAGES
    Users with basic programming skills can generate an interactive graph for web browsers in HTML, CSS, and JavaScript/React

  2. START WITH A SIMPLIFIED VERSION

    • Sketch the main visual elements and interactions
    • Test how it works
    • Add more sophisticated features step-by-step
  3. USE CONSOLE MESSAGES TO AID THE AI WITH DEBUGGING

    • Reporting Java console error messages as well as unexpected behaviour lets the AI resolve issues faster
    • To access Java console, right-click on the browser window, choose “Inspect” from the pop-up menu to open the side panel, click on “Console” in the side panel menu
    • If the error names a line of code where the bug is located, open “Source” in the side panel to look it up
  4. INCREMENTAL DEBUGGING

    • Add features one at a time. When stuck, revert to last working version.
    • If the AI still gets caught in a loop (“context poisoning”?), start a new thread
  5. SIMPLICITY WINS

    • Complex state management consistently led to bugs, simpler solutions are easier to maintain and debug
    • Let D3 force simulation handle physics - avoid over-controlling it
  6. ORDER MATTERS

    • JavaScript/React initialization order is critical - declare before use
    • Never set fx/fy before x/y exist: respect simulation lifecycle
  7. DIRECT MANIPULATION
    Force-graph library mutates node objects in place - work with them directly

  8. EMBRACE PLATFORM BEHAVIORS

    • Ask AI to check browser compatibility
    • When accessing links, work with browser policies (autoplay, cookies) not against them
  9. SEPARATION OF CONCERNS
    Canvas for performance-critical rendering, DOM for rich interactive content

  10. USER INSIGHTS MATTER
    Claude found many relevant web sources, but editing the information displayed in the graph was still a major part of the user input. Consider the AI a subject matter expert for implementing and explaining code, but even here do not assume it has all the answers, e.g.:

  • Claude assumed the existence of API methods that do not exist (clues for this were found in the console messages)
  • we got into a loop of abortive attempts to debug an issue that made the code more and more complicated and unreliable, but user experience of the graph led to a simple and robust solution
  1. REVISE CODE PERIODICALLY DURING ITS EVOLUTION
    Ask the AI to check for code for robustness, efficiency, and maintainability. Remove any parts that may have become obsolete during the previous editing cycles. If the AI suggests substantial refactoring, it is likely that this will help the subsequent development.

  2. USE AI FOR PROJECT DOCUMENTATION

  • When the code is finalized ask AI to add annotations prepare a “ReadMe” manual
  • Turn project into a learning experience: ask for a draft summary of major challenges, solutions and key insights gained

3.2. Learnings from debugging project specific issues


1. INITIALIZATION & RENDER LOOP ISSUES

Problem: Graph appeared briefly and vanished, or page completely blank

Root Causes: * Variable accessed before declaration (nodePositions referenced before defined) * Calling graphRef.current.graphData() as function (it’s a property) * useEffect running before nodes had coordinates (fx/fy on undefined values) * Missing useMemo causing infinite re-renders

Solution: * Strict declaration order: refs → state → constants → effects → functions * Work directly with filteredData.nodes instead of trying to access nodes though graphRef.current.graphData() * Ensure fx/fy only set when x/y !== undefined * Add useMemo for filtered data calculations

2. NODE PINNING & FORCE SIMULATION

Problem: Nodes drifting on cursor hover, making tooltips unreadable and links unclickable

Root Cause: Force simulation running indefinitely without pinning nodes

Solution: Pin nodes when simulation stops

javascript

const handleEngineStop = () => {
    filteredData.nodes.forEach(node => {
        node.fx = node.x;
        node.fy = node.y;
    });
};

Insight: Work directly with node objects in filteredData - the library mutates them in place

3. POSITION LOADING & CUSTOM LAYOUT

Aim: Nodes arranged in custom star shape on initialization, staying fixed until user interaction

Issue: An initial attempt to let the AI arrange the star shape utterly failed (LLMs like Claude are known to be bad at spatial tasks 1)

Solution: Simple useEffect after data loads (complex state management led to bugs

javascript

useEffect(() => {
    const hasPositions = Object.keys(nodePositions).length > 0;
    if (hasPositions && filteredData.nodes.length > 0) {
        filteredData.nodes.forEach(node => {
            if (nodePositions[node.id]) {
                node.x = nodePositions[node.id].x;
                node.y = nodePositions[node.id].y;
                node.fx = nodePositions[node.id].x;  // Fixed
                node.fy = nodePositions[node.id].y;
            }
        });
    }
}, [filteredData, nodePositions]);

For the arrangement of nodes in star shape: 1. Added a temporary button for JAVA console export 2. Manually arranged nodes, exported position to console 3. Copied positions, fed them back to AI, removed export button

4. LINK DISTANCE SLIDER ISSUES

Problem 1: Once nodes were pinned, link distance slider had no effect

Root Cause: Pinned nodes (fx/fy set) ignore force simulation changes

Problem 2: Slider stopped working after deselecting/reselecting categories

Root Cause: Nodes remained pinned through category filter changes

Solution: Unpin nodes when slider moves (user suggestion)

javascript

useEffect(() => {
    if (!graphRef.current) return;
    
    const graph = graphRef.current;  
    const linkForce = graph.d3Force('link');
    
    if (linkForce) {
        if (linkDistance !== 100 && filteredData.nodes.length > 0) {
            filteredData.nodes.forEach(node => {
                node.fx = null;
                node.fy = null;
            });
        }
        linkForce.distance(linkDistance);
        graph.d3ReheatSimulation();
    }
}, [linkDistance, filteredData]);


5. API METHOD CONFUSION

Problem: AI attempted to use non-existent ForceGraph2D methods during debugging: * graphRef.current.graphData() (a property, not a method) * graphRef.current.refresh()

Correct API Methods: ✓ graphRef.current.d3Force(‘link’) - access force simulation ✓ graphRef.current.d3ReheatSimulation() - restart simulation ✓ Direct node property manipulation (node.fx, node.fy)

Solution: AI checked library documentation to confirm API methods exist

6. VISUALIZATION FEATURES

Aim: Show second degree connections between filtered nodes

Solution - findIndirectConnections() algorithm:

  • Build adjacency map of all connections in full graph
  • For each visible node pair, check if they share hidden neighbors
  • Create dashed links: linkLineDash: [5, 5]

Aim: Represent nodes belonging to multiple categories. Solution - Custom drawMultiColorStar() function:

  • Calculate points per category: 1 cat = 5 points, 2 cats = 8 points (4 each), 3 cats = 9 points (3 each)
  • Draw each spike individually with category color
  • Cycle through categories using Math.floor(i / pointsPerCategory)

Problem: Country flags displayed as letter codes instead of emojis

Solution:

  • Use Unicode flag emoji characters directly: 🇩🇪 🇬🇧 🇳🇱 🇮🇹 🇺🇸
  • Emojis display in hover panel (DOM) not canvas
  • Conditional rendering for URL vs character emojis Key Learning: Canvas vs DOM - use canvas for performance, DOM for rich content

Aim: Shooting star animation - add ambient background without affecting graph performance

Solution:

  • Separate React component with own state
  • CSS animations (hardware-accelerated) not JavaScript
  • Random timing with setTimeout cleanup
  • Absolute positioning with high z-index layering

7. BACKGROUND MUSIC (not implemented in the final graph)

Issue:

  • Embedded YouTube player requires cookie acceptance
  • Play button unresponsive until cookie decision
  • Cannot legally automate consent (GDPR violation)

Solution:

  • Replace YouTube with HTML5 audio player
  • Stream MP3 from GitHub repository

Issue: Modern browsers block autoplay-with-sound by default (intentional browser security) Solution: Embrace browser behavior

  • Keep autoPlay: true attribute (may work in some cases)
  • Provide clear Play button as primary control

4. Overall architecture


TECHNOLOGY STACK:

  • React 18 for UI controls and state management
  • react-force-graph-2d for graph visualization
  • D3 force simulation for physics
  • Canvas for high-performance node rendering
  • HTML5 Audio API for music playback

STATE MANAGEMENT STRATEGY:

  • React state for UI interactions
  • useRef for DOM references and image cache
  • useMemo for expensive computations (filtered data, stats)
  • useEffect for side effects (images, positions)

DATA STRUCTURE:

  • Nodes with flexible category system (single or array)
  • Links with source/target relationships
  • Pre-computed positions for deterministic layout

5. Project statistitcs


PROJECT SCOPE:

  • 43 nodes (Christmas traditions, people, places, symbols, foods)
  • 59 edges (relationships between concepts)
  • 7 categories
  • 6 relationship types
  • Custom star pattern layout with 43 pre-computed positions
  • Multi-category node visualization
  • Indirect connection discovery
  • Filtering and highlighting systems

LINES OF CODE: ~1,300 lines (HTML, CSS, JavaScript, React)

DEVELOPMENT TIME: Multiple iterative sessions across 3 major stages

MAJOR REWRITES: 3-4 complete refactors to resolve fundamental issues

7. Footnotes


1 “I have a metal cup, but its top opening is welded shut”

2 Links must be opened in a new tab (right click -> “Open link in new tab”)