Download script.js from hfmlsoc/os_gpai_guide_flowchart: direct link, hf CLI and curl.
- Browser
- Download file 26.8 kB
-
https://huggingface.co/spaces/hfmlsoc/os_gpai_guide_flowchart/resolve/main/script.js
- Command line
-
hf download hf://spaces/hfmlsoc/os_gpai_guide_flowchart/script.js
-
curl -L -o script.js https://huggingface.co/spaces/hfmlsoc/os_gpai_guide_flowchart/resolve/main/script.js
26.8 kB
| // Define flowchart data structure | |
| const flowchartData = { | |
| q1: { | |
| text: "Did you develop the GPAI model for the sole purpose of scientific research and development?", | |
| type: "question", | |
| context: "You develop a GPAI model", | |
| choices: [ | |
| { text: "Yes", next: "outcome_research" }, | |
| { text: "No", next: "q2" } | |
| ] | |
| }, | |
| q2: { | |
| text: "Have you made the GPAI model available on the EU market including via a commercial activity or via API or open repository?", | |
| type: "question", | |
| choices: [ | |
| { text: "Yes", next: "q3" }, | |
| { text: "No", next: "outcome_research" } | |
| ] | |
| }, | |
| q3: { | |
| text: "Does the GPAI model you've published qualify as posing a potential systemic risk?", | |
| type: "question", | |
| choices: [ | |
| { text: "Yes", next: "outcome_systemic_risk" }, | |
| { text: "No", next: "q4" } | |
| ] | |
| }, | |
| q4: { | |
| text: "Have you published the GPAI model under a free and open-source license along with documentation about model architecture and usage?", | |
| type: "question", | |
| choices: [ | |
| { text: "Yes", next: "q5" }, | |
| { text: "No", next: "outcome_gpai_provider_with_obligations" } | |
| ] | |
| }, | |
| q5: { | |
| text: "Are you monetising the GPAI model by making its availability contingent on payment, procuring other products/services, viewing ads, or receiving/processing personal data?", | |
| type: "question", | |
| choices: [ | |
| { text: "Yes", next: "outcome_gpai_provider_with_obligations" }, | |
| { text: "No", next: "outcome_open_source" } | |
| ] | |
| }, | |
| outcome_research: { | |
| text: "You're not a GPAI provider. GPAI provisions do not apply.", | |
| type: "outcome" | |
| }, | |
| outcome_systemic_risk: { | |
| text: "You're a GPAISR provider. Open source exemptions from GPAI provisions do not apply.", | |
| type: "outcome", | |
| articles: ["Article 53(1)(a)", "Article 53(1)(b)", "Article 53(1)(c)", "Article 53(1)(d)", "Article 54", "Article 55"], | |
| additional: "Additional obligations for GPAI with Systemic Risk: Article 55" | |
| }, | |
| outcome_gpai_provider_with_obligations: { | |
| text: "You're a GPAI provider. Open source exemptions from GPAI provisions do not apply.", | |
| type: "outcome", | |
| class: "gpai", | |
| articles: ["Article 53(1)(a)", "Article 53(1)(b)", "Article 53(1)(c)", "Article 53(1)(d)", "Article 54"], | |
| }, | |
| outcome_open_source: { | |
| text: "You're an open-source GPAI provider. Open source exemptions apply.", | |
| type: "outcome", | |
| class: "gpai", | |
| articles: ["Article 53(1)(a)", "Article 53(1)(b)"], | |
| } | |
| }; | |
| // State variables | |
| let currentNode = 'q1'; | |
| let navigationHistory = []; | |
| // Zoom and pan variables for flowchart | |
| let scale = 1; | |
| let translateX = 0; | |
| let translateY = 0; | |
| let isDragging = false; | |
| let lastMousePos = { x: 0, y: 0 }; | |
| // Initialize the app | |
| document.addEventListener('DOMContentLoaded', function() { | |
| updateGuidedView(); | |
| initializeMermaid(); | |
| // Set up mode switching | |
| document.getElementById('guided-btn').addEventListener('click', () => switchMode('guided')); | |
| document.getElementById('flowchart-btn').addEventListener('click', () => switchMode('flowchart')); | |
| }); | |
| function switchMode(mode) { | |
| const guidedMode = document.getElementById('guided-mode'); | |
| const flowchartMode = document.getElementById('flowchart-mode'); | |
| const guidedBtn = document.getElementById('guided-btn'); | |
| const flowchartBtn = document.getElementById('flowchart-btn'); | |
| if (mode === 'guided') { | |
| guidedMode.classList.remove('hidden'); | |
| flowchartMode.classList.add('hidden'); | |
| guidedBtn.classList.add('active'); | |
| flowchartBtn.classList.remove('active'); | |
| } else { | |
| guidedMode.classList.add('hidden'); | |
| flowchartMode.classList.remove('hidden'); | |
| guidedBtn.classList.remove('active'); | |
| flowchartBtn.classList.add('active'); | |
| } | |
| } | |
| // Guided mode functions | |
| function updateGuidedView() { | |
| updateContext(); | |
| updateHistory(); | |
| updateCurrentQuestion(); | |
| updateArticles(); | |
| } | |
| function updateContext() { | |
| const contextElement = document.querySelector('.context-text'); | |
| const currentData = flowchartData[currentNode]; | |
| contextElement.textContent = currentData.context || "You develop a GPAI model"; | |
| } | |
| function updateHistory() { | |
| const historyContainer = document.querySelector('.history-section'); | |
| const historyHTML = navigationHistory.map((item, index) => ` | |
| <div class="history-item" onclick="goToHistoryStep(${index})"> | |
| <div class="history-question">${truncateText(item.question, 50)}</div> | |
| <div class="history-answer">→ ${item.answer}</div> | |
| <div class="tooltip">${item.question}</div> | |
| </div> | |
| `).join(''); | |
| historyContainer.innerHTML = ` | |
| <h3>Decision History</h3> | |
| ${historyHTML} | |
| `; | |
| } | |
| function updateCurrentQuestion() { | |
| const container = document.getElementById('question-container'); | |
| const currentData = flowchartData[currentNode]; | |
| if (currentData.type === 'question') { | |
| container.innerHTML = ` | |
| <div class="question">${currentData.text}</div> | |
| <div class="choices"> | |
| ${currentData.choices.map(choice => | |
| `<button class="choice-btn" onclick="nextQuestion('${choice.next}', '${choice.text}')">${choice.text}</button>` | |
| ).join('')} | |
| </div> | |
| `; | |
| } else { | |
| // Outcome | |
| let articlesHTML = ''; | |
| if (currentData.articles) { | |
| const articlesList = currentData.articles.map(article => | |
| `<li>${article}</li>` | |
| ).join(''); | |
| articlesHTML = ` | |
| <div class="articles-section" style="margin-top: 30px;"> | |
| <h3>Applicable Articles</h3> | |
| <ul class="articles-list"> | |
| ${articlesList} | |
| </ul> | |
| </div> | |
| `; | |
| } | |
| container.innerHTML = ` | |
| <div class="outcome ${currentData.class || ''}">${currentData.text}</div> | |
| ${articlesHTML} | |
| <button class="restart-btn" onclick="restart()">Start Over</button> | |
| `; | |
| } | |
| } | |
| function updateArticles() { | |
| // This function is no longer needed since articles are now displayed in updateCurrentQuestion | |
| // But we'll keep it empty to avoid breaking the updateGuidedView call | |
| } | |
| function nextQuestion(nodeId, selectedChoice = null) { | |
| if (selectedChoice) { | |
| navigationHistory.push({ | |
| node: currentNode, | |
| question: flowchartData[currentNode].text, | |
| answer: selectedChoice, | |
| nextNode: nodeId | |
| }); | |
| } | |
| currentNode = nodeId; | |
| updateGuidedView(); | |
| } | |
| function goToHistoryStep(stepIndex) { | |
| navigationHistory = navigationHistory.slice(0, stepIndex); | |
| currentNode = stepIndex === 0 ? 'q1' : navigationHistory[stepIndex - 1].nextNode; | |
| updateGuidedView(); | |
| } | |
| function restart() { | |
| currentNode = 'q1'; | |
| navigationHistory = []; | |
| updateGuidedView(); | |
| } | |
| function truncateText(text, maxLength) { | |
| return text.length > maxLength ? text.substring(0, maxLength) + '...' : text; | |
| } | |
| // Create dynamic SVG flowchart from flowchartData | |
| function initializeMermaid() { | |
| const svg = generateFlowchartSVG(); | |
| document.getElementById('mermaid-container').innerHTML = svg; | |
| setupFlowchartInteraction(); | |
| } | |
| function generateFlowchartSVG() { | |
| // Layout configuration | |
| const config = { | |
| startX: 200, | |
| startY: 60, | |
| questionSpacing: 220, | |
| outcomeY: 1450, // Moved further down from 1350 | |
| outcomeSpacing: 350, | |
| articleOffset: 70, // Reduced from 140 to bring articles closer | |
| rightOutcomeX: 600, | |
| diamondSize: 100 | |
| }; | |
| let svgContent = `<svg viewBox="0 0 1600 1600" style="width: 100%; height: auto;">`; // Increased height | |
| // Add arrow marker definition | |
| svgContent += ` | |
| <defs> | |
| <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"> | |
| <polygon points="0 0, 10 3.5, 0 7" fill="#333"/> | |
| </marker> | |
| </defs> | |
| `; | |
| // Generate start node | |
| const startY = config.startY; | |
| svgContent += generateStartNode(config.startX, startY); | |
| // Generate questions vertically | |
| const questions = ['q1', 'q2', 'q3', 'q4', 'q5']; | |
| questions.forEach((qId, index) => { | |
| const y = startY + 60 + (index + 1) * config.questionSpacing; | |
| svgContent += generateQuestion(qId, config.startX, y); | |
| // Add connecting line from previous element | |
| const prevY = index === 0 ? startY + 30 : startY + 60 + index * config.questionSpacing + config.diamondSize; | |
| svgContent += `<line x1="${config.startX}" y1="${prevY}" x2="${config.startX}" y2="${y - config.diamondSize}" stroke="#333" stroke-width="2" marker-end="url(#arrowhead)"/>`; | |
| // Add correct label for vertical flow (except last question) | |
| if (index < questions.length - 1) { | |
| // Q2→Q3 and Q4→Q5 should be "Yes", Q1→Q2 and Q3→Q4 should be "No" | |
| const label = (index === 1 || index === 3) ? "Yes" : "No"; // Q2(index=1)→Q3 and Q4(index=3)→Q5 | |
| svgContent += `<text x="${config.startX + 20}" y="${y + config.diamondSize + 20}" text-anchor="start" font-size="12" font-weight="bold" fill="#333">${label}</text>`; | |
| } | |
| }); | |
| // Generate outcome_research (right of q2, not q1) | |
| svgContent += generateOutcome('outcome_research', config.rightOutcomeX, startY + 60 + 2 * config.questionSpacing); // Changed to Q2 position | |
| svgContent += generateArticles('outcome_research', config.rightOutcomeX + config.articleOffset, startY + 60 + 2 * config.questionSpacing - 40); | |
| // Arrow from Q1 Yes to outcome_research (curved to reach Q2 level) | |
| const q1Y = startY + 60 + config.questionSpacing; | |
| const q2Y = startY + 60 + 2 * config.questionSpacing; | |
| svgContent += `<line x1="${config.startX + config.diamondSize}" y1="${q1Y}" x2="${config.rightOutcomeX - 150}" y2="${q1Y}" stroke="#333" stroke-width="2"/>`; | |
| svgContent += `<line x1="${config.rightOutcomeX - 150}" y1="${q1Y}" x2="${config.rightOutcomeX - 150}" y2="${q2Y}" stroke="#333" stroke-width="2"/>`; | |
| svgContent += `<line x1="${config.rightOutcomeX - 150}" y1="${q2Y}" x2="${config.rightOutcomeX - 60}" y2="${q2Y}" stroke="#333" stroke-width="2" marker-end="url(#arrowhead)"/>`; | |
| svgContent += `<text x="${config.startX + 140}" y="${q1Y - 5}" text-anchor="middle" font-size="12" font-weight="bold" fill="#333">Yes</text>`; | |
| // Arrow from Q2 No to outcome_research (straight right) | |
| svgContent += `<line x1="${config.startX + config.diamondSize}" y1="${q2Y}" x2="${config.rightOutcomeX - 60}" y2="${q2Y}" stroke="#333" stroke-width="2" marker-end="url(#arrowhead)"/>`; | |
| svgContent += `<text x="${config.startX + 140}" y="${q2Y - 5}" text-anchor="middle" font-size="12" font-weight="bold" fill="#333">No</text>`; | |
| // Generate bottom row outcomes | |
| const bottomOutcomes = ['outcome_open_source', 'outcome_gpai_provider_with_obligations', 'outcome_systemic_risk']; | |
| bottomOutcomes.forEach((outcomeId, index) => { | |
| const x = config.startX + index * config.outcomeSpacing; | |
| // Generate outcome circle | |
| svgContent += generateOutcome(outcomeId, x, config.outcomeY); | |
| // Generate articles to the right | |
| svgContent += generateArticles(outcomeId, x + config.articleOffset, config.outcomeY - 40); | |
| // Add connecting arrows based on outcome type | |
| svgContent += generateOutcomeArrow(outcomeId, x, config); | |
| }); | |
| svgContent += `</svg>`; | |
| return svgContent; | |
| } | |
| function generateStartNode(x, y) { | |
| return ` | |
| <ellipse cx="${x}" cy="${y}" rx="100" ry="30" fill="#FFA726" stroke="#FF8F00" stroke-width="3"/> | |
| <text x="${x}" y="${y + 5}" text-anchor="middle" font-size="14" font-weight="bold" fill="#333">You develop a GPAI model</text> | |
| `; | |
| } | |
| function generateQuestion(questionId, x, y) { | |
| const question = flowchartData[questionId]; | |
| if (!question) return ''; | |
| const lines = wrapText(question.text, 25); | |
| const lineHeight = 15; | |
| const totalHeight = lines.length * lineHeight; | |
| const startY = y - totalHeight / 2 + lineHeight / 2; | |
| const size = 100; // Larger diamond size | |
| let content = `<polygon points="${x},${y-size} ${x-size},${y} ${x},${y+size} ${x+size},${y}" fill="#A5D6A7" stroke="#4CAF50" stroke-width="3"/>`; | |
| lines.forEach((line, index) => { | |
| content += `<text x="${x}" y="${startY + index * lineHeight}" text-anchor="middle" font-size="11" font-weight="bold" fill="#333">${line}</text>`; | |
| }); | |
| return content; | |
| } | |
| function generateOutcome(outcomeId, x, y) { | |
| const outcome = flowchartData[outcomeId]; | |
| if (!outcome) return ''; | |
| const lines = wrapText(outcome.text, 18); | |
| const lineHeight = 15; | |
| const totalHeight = lines.length * lineHeight; | |
| const startY = y - totalHeight / 2 + lineHeight / 2; | |
| let content = `<circle cx="${x}" cy="${y}" r="60" fill="#333"/>`; | |
| lines.forEach((line, index) => { | |
| content += `<text x="${x}" y="${startY + index * lineHeight}" text-anchor="middle" font-size="10" fill="white" font-weight="bold">${line}</text>`; | |
| }); | |
| return content; | |
| } | |
| function generateArticles(outcomeId, x, y) { | |
| const outcome = flowchartData[outcomeId]; | |
| if (!outcome || !outcome.articles) return ''; | |
| // Dynamic sizing based on content | |
| const articleCount = outcome.articles.length; | |
| const width = 180; | |
| const height = Math.max(100, articleCount * 18 + 60); // Better height calculation | |
| // Top-aligned with the outcome circle (circle is at y, radius 60, so top is y-60) | |
| const adjustedY = y; // Align to top of circle instead of center | |
| let content = `<rect x="${x}" y="${adjustedY}" width="${width}" height="${height}" fill="#E8F5E8" stroke="#4CAF50" stroke-width="3" rx="10"/>`; | |
| // Title | |
| const titleLines = wrapText(`Applicable obligations for ${getOutcomeTitle(outcomeId)}`, 22); | |
| let titleY = adjustedY + 20; | |
| titleLines.forEach((line, index) => { | |
| content += `<text x="${x + width/2}" y="${titleY + index * 15}" text-anchor="middle" font-size="11" font-weight="bold" fill="#333">${line}</text>`; | |
| }); | |
| // Articles with better spacing | |
| let articleStartY = adjustedY + 20 + titleLines.length * 15 + 15; | |
| outcome.articles.forEach((article, index) => { | |
| const articleY = articleStartY + index * 18; // Increased spacing between articles | |
| content += `<text x="${x + 15}" y="${articleY}" text-anchor="start" font-size="10" fill="#667eea">${article}</text>`; | |
| }); | |
| return content; | |
| } | |
| function generateOutcomeArrow(outcomeId, x, config) { | |
| const questionY = config.startY + 60; | |
| let content = ''; | |
| switch(outcomeId) { | |
| case 'outcome_open_source': | |
| // From Q5 No (straight down) | |
| const q5Y = questionY + 5 * config.questionSpacing; | |
| content = ` | |
| <line x1="${config.startX}" y1="${q5Y + config.diamondSize}" x2="${config.startX}" y2="${config.outcomeY - 60}" stroke="#333" stroke-width="2" marker-end="url(#arrowhead)"/> | |
| <text x="${config.startX - 30}" y="${q5Y + config.diamondSize + 40}" text-anchor="middle" font-size="12" font-weight="bold" fill="#333">No</text> | |
| `; | |
| break; | |
| case 'outcome_gpai_provider_with_obligations': | |
| // From Q4 No and Q5 Yes - combine to same circle | |
| const q4Y = questionY + 4 * config.questionSpacing; | |
| const q5Y_yes = questionY + 5 * config.questionSpacing; | |
| const midPointX = x - 50; // Meeting point for both arrows | |
| content = ` | |
| <!-- Q4 No path --> | |
| <line x1="${config.startX + config.diamondSize}" y1="${q4Y}" x2="${midPointX}" y2="${q4Y}" stroke="#333" stroke-width="2"/> | |
| <line x1="${midPointX}" y1="${q4Y}" x2="${midPointX}" y2="${config.outcomeY - 100}" stroke="#333" stroke-width="2"/> | |
| <text x="${config.startX + 150}" y="${q4Y - 5}" text-anchor="middle" font-size="12" font-weight="bold" fill="#333">No</text> | |
| <!-- Q5 Yes path --> | |
| <line x1="${config.startX + config.diamondSize}" y1="${q5Y_yes}" x2="${midPointX}" y2="${q5Y_yes}" stroke="#333" stroke-width="2"/> | |
| <line x1="${midPointX}" y1="${q5Y_yes}" x2="${midPointX}" y2="${config.outcomeY - 100}" stroke="#333" stroke-width="2"/> | |
| <text x="${config.startX + 150}" y="${q5Y_yes - 5}" text-anchor="middle" font-size="12" font-weight="bold" fill="#333">Yes</text> | |
| <!-- Combined path to circle --> | |
| <line x1="${midPointX}" y1="${config.outcomeY - 100}" x2="${x}" y2="${config.outcomeY - 60}" stroke="#333" stroke-width="2" marker-end="url(#arrowhead)"/> | |
| `; | |
| break; | |
| case 'outcome_systemic_risk': | |
| // From Q3 Yes (horizontal then down) | |
| const q3Y = questionY + 3 * config.questionSpacing; | |
| content = ` | |
| <line x1="${config.startX + config.diamondSize}" y1="${q3Y}" x2="${x}" y2="${q3Y}" stroke="#333" stroke-width="2"/> | |
| <line x1="${x}" y1="${q3Y}" x2="${x}" y2="${config.outcomeY - 60}" stroke="#333" stroke-width="2" marker-end="url(#arrowhead)"/> | |
| <text x="${config.startX + 150}" y="${q3Y - 5}" text-anchor="middle" font-size="12" font-weight="bold" fill="#333">Yes</text> | |
| `; | |
| break; | |
| } | |
| return content; | |
| } | |
| function getOutcomeTitle(outcomeId) { | |
| switch(outcomeId) { | |
| case 'outcome_research': return 'research providers'; | |
| case 'outcome_open_source': return 'open-source GPAI providers'; | |
| case 'outcome_gpai_provider_with_obligations': return 'GPAI providers'; | |
| case 'outcome_systemic_risk': return 'GPAISR providers'; | |
| default: return 'providers'; | |
| } | |
| } | |
| function wrapText(text, maxLength) { | |
| const words = text.split(' '); | |
| const lines = []; | |
| let currentLine = ''; | |
| words.forEach(word => { | |
| if ((currentLine + word).length <= maxLength) { | |
| currentLine += (currentLine ? ' ' : '') + word; | |
| } else { | |
| if (currentLine) lines.push(currentLine); | |
| currentLine = word; | |
| } | |
| }); | |
| if (currentLine) lines.push(currentLine); | |
| return lines; | |
| } | |
| // Flowchart interaction functions (zoom, pan) | |
| function updateTransform() { | |
| const mermaidContainer = document.getElementById('mermaid-container'); | |
| if (mermaidContainer) { | |
| mermaidContainer.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`; | |
| } | |
| } | |
| function zoomIn() { | |
| scale = Math.min(scale * 1.2, 3); | |
| updateTransform(); | |
| } | |
| function zoomOut() { | |
| scale = Math.max(scale / 1.2, 0.3); | |
| updateTransform(); | |
| } | |
| function resetView() { | |
| scale = 1; | |
| translateX = 0; | |
| translateY = 0; | |
| updateTransform(); | |
| } | |
| function setupFlowchartInteraction() { | |
| const container = document.getElementById('flowchart-container'); | |
| if (!container) return; | |
| // Mouse events for dragging | |
| container.addEventListener('mousedown', function(e) { | |
| isDragging = true; | |
| lastMousePos = { x: e.clientX, y: e.clientY }; | |
| e.preventDefault(); | |
| }); | |
| document.addEventListener('mousemove', function(e) { | |
| if (!isDragging) return; | |
| const deltaX = e.clientX - lastMousePos.x; | |
| const deltaY = e.clientY - lastMousePos.y; | |
| translateX += deltaX; | |
| translateY += deltaY; | |
| updateTransform(); | |
| lastMousePos = { x: e.clientX, y: e.clientY }; | |
| }); | |
| document.addEventListener('mouseup', function() { | |
| isDragging = false; | |
| }); | |
| // Wheel zoom | |
| container.addEventListener('wheel', function(e) { | |
| e.preventDefault(); | |
| const rect = container.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| const zoom = e.deltaY > 0 ? 0.9 : 1.1; | |
| const newScale = Math.min(Math.max(scale * zoom, 0.3), 3); | |
| const factor = newScale / scale; | |
| translateX = x - (x - translateX) * factor; | |
| translateY = y - (y - translateY) * factor; | |
| scale = newScale; | |
| updateTransform(); | |
| }); | |
| // Touch events for mobile | |
| let touchStartDistance = 0; | |
| let touchStartScale = 1; | |
| container.addEventListener('touchstart', function(e) { | |
| if (e.touches.length === 1) { | |
| isDragging = true; | |
| lastMousePos = { x: e.touches[0].clientX, y: e.touches[0].clientY }; | |
| } else if (e.touches.length === 2) { | |
| isDragging = false; | |
| const touch1 = e.touches[0]; | |
| const touch2 = e.touches[1]; | |
| touchStartDistance = Math.sqrt( | |
| Math.pow(touch2.clientX - touch1.clientX, 2) + | |
| Math.pow(touch2.clientY - touch1.clientY, 2) | |
| ); | |
| touchStartScale = scale; | |
| } | |
| e.preventDefault(); | |
| }); | |
| container.addEventListener('touchmove', function(e) { | |
| if (e.touches.length === 1 && isDragging) { | |
| const deltaX = e.touches[0].clientX - lastMousePos.x; | |
| const deltaY = e.touches[0].clientY - lastMousePos.y; | |
| translateX += deltaX; | |
| translateY += deltaY; | |
| updateTransform(); | |
| lastMousePos = { x: e.touches[0].clientX, y: e.touches[0].clientY }; | |
| } else if (e.touches.length === 2) { | |
| const touch1 = e.touches[0]; | |
| const touch2 = e.touches[1]; | |
| const currentDistance = Math.sqrt( | |
| Math.pow(touch2.clientX - touch1.clientX, 2) + | |
| Math.pow(touch2.clientY - touch1.clientY, 2) | |
| ); | |
| scale = Math.min(Math.max(touchStartScale * (currentDistance / touchStartDistance), 0.3), 3); | |
| updateTransform(); | |
| } | |
| e.preventDefault(); | |
| }); | |
| container.addEventListener('touchend', function(e) { | |
| isDragging = false; | |
| e.preventDefault(); | |
| }); | |
| } |