-
- All ${key}s
- ${values.map((v) => `${v} `).join("")}
-
+ (key) => /* html */ `
+
+ `
)
.join("");
+
+ filterKeys.forEach((key) => renderFilterOptions(key));
+ addFilterEventListeners();
+ selectTopTeams();
}
-function filteredIncidents() {
- return data.incidents.filter((row) => {
- return ["Area", "Shift", "Team", "Service"].every((key) => {
- const value = document.querySelector(`select[name="${key}"]`)?.value;
- return !value || row[key] === value;
+function renderFilterOptions(key) {
+ const optionsContainer = document.getElementById(`filter-options-${key}`);
+ const options = filters[key];
+
+ options.sort((a, b) => (a.selected === b.selected ? a.index - b.index : b.selected - a.selected));
+
+ optionsContainer.innerHTML = options
+ .map(
+ (option) => /* html */`
+
+
+ ${option.value}
+
+ `
+ )
+ .join("");
+
+ optionsContainer.querySelectorAll(".filter-checkbox").forEach((checkbox) => {
+ checkbox.addEventListener("change", () => {
+ const option = filters[key].find((opt) => opt.value === checkbox.value);
+ if (option) option.selected = checkbox.checked;
+ if (key === "Service") selectTopTeams();
+ renderFilterOptions(key);
+ update();
+ });
+ });
+}
+
+function addFilterEventListeners() {
+ document.querySelectorAll(".search-filter").forEach((input) => {
+ input.addEventListener("input", (e) => {
+ const searchText = e.target.value.toLowerCase();
+ const dropdownMenu = e.target.closest(".dropdown-menu");
+ dropdownMenu.querySelectorAll(".dropdown-item").forEach((item) => {
+ const label = item.querySelector("label");
+ if (label) {
+ item.style.display = label.textContent.toLowerCase().includes(searchText) ? "" : "none";
+ }
+ });
+ });
+ });
+
+ document.querySelectorAll(".select-all").forEach((checkbox) => {
+ checkbox.addEventListener("change", (e) => {
+ const key = e.target.id.replace("all-", "");
+ const checked = e.target.checked;
+ filters[key].forEach((option) => (option.selected = checked));
+ if (key === "Service") selectTopTeams();
+ renderFilterOptions(key);
+ update();
});
});
+
+ document.querySelectorAll(".top-10").forEach((checkbox) => {
+ checkbox.addEventListener("change", (e) => {
+ const key = e.target.id.replace("top-10-", "");
+ const checked = e.target.checked;
+ const top10Services = ["CTR", "LOGAN", "VaR", "GRT", "LIQ", "RWH", "Argos", "PXV", "TLM", "K2", "TARDIS"];
+
+ filters[key].forEach((option) => {
+ option.selected = top10Services.includes(option.value) ? checked : false;
+ });
+
+ const selectAll = document.getElementById(`all-${key}`);
+ if (selectAll) selectAll.checked = false;
+
+ selectTopTeams();
+ renderFilterOptions(key);
+ update();
+ });
+ });
+
+ document.querySelectorAll(".dropdown-menu").forEach((menu) => {
+ menu.addEventListener("click", (e) => e.stopPropagation());
+ });
+}
+
+function selectTopTeams() {
+ const selectedServices = filters["Service"].filter((opt) => opt.selected).map((opt) => opt.value);
+
+ filters["Team"].forEach((option) => (option.selected = false));
+
+ if (selectedServices.length === 0) {
+ renderFilterOptions("Team");
+ return;
+ }
+
+ const incidentsByTeamService = d3.rollups(
+ data.incidents,
+ (v) => d3.sum(v, (d) => d.Count),
+ (d) => d.Service,
+ (d) => d.Team
+ );
+
+ const topTeams = new Set();
+
+ incidentsByTeamService.forEach(([service, teams]) => {
+ if (selectedServices.includes(service)) {
+ teams
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 2)
+ .forEach(([team]) => topTeams.add(team));
+ }
+ });
+
+ filters["Team"].forEach((option) => {
+ if (topTeams.has(option.value)) option.selected = true;
+ });
+
+ renderFilterOptions("Team");
}
-$filters.addEventListener("change", update);
+function filteredIncidents() {
+ const selectedValues = {};
+ const filterKeys = ["Area", "Shift", "Team", "Service"];
+
+ filterKeys.forEach((key) => {
+ selectedValues[key] = filters[key].filter((opt) => opt.selected).map((opt) => opt.value);
+ });
+
+ return data.incidents.filter((row) =>
+ filterKeys.every((key) => selectedValues[key].length === 0 || selectedValues[key].includes(row[key]))
+ );
+}
function drawSankey() {
- // Filter data based on selected values
+ const incidents = filteredIncidents();
const graph = sankey($sankey, {
- data: filteredIncidents(),
+ data: incidents,
labelWidth: 100,
categories: ["Shift", "Area", "Team", "Service"],
size: (d) => d.Count,
- text: (d) => (d.width > 20 ? d.key : null),
+ text: (d) => (d.key.length * 9 < d.width ? d.key : null),
d3,
});
graphs.sankey = graph;
- // Calculate average duration
- graph.nodeData.forEach((d) => (d.Hours = d3.sum(d.group, (d) => d.Hours) / d.group.length));
- graph.linkData.forEach((d) => (d.Hours = d3.sum(d.group, (d) => d.Hours) / d.group.length));
-
- // Calculate the 5th and 95th percentiles of d.Hours, weighted by d.size
- const sorted = d3.sort(graph.nodeData, (d) => d.Hours);
- const totalSize = d3.sum(sorted, (d) => d.size);
- let cumulative = 0;
- for (const [i, d] of sorted.entries()) {
- cumulative += d.size / totalSize;
- d.cumulative = cumulative;
- d.percentrank = i / (sorted.length - 1);
- }
- const p5 = sorted.find((d) => d.cumulative >= 0.05).Hours;
- const p95 = [...sorted].reverse().find((d) => d.cumulative <= 0.95).Hours;
- extent = [p95, (p95 + p5) / 2, p5];
- d3.sort(graph.linkData, (d) => d.Hours).forEach((d, i) => (d.percentrank = i / (graph.linkData.length - 1)));
+ graph.nodeData.forEach((d) => {
+ const totalAdjustedHours = d3.sum(d.group, (d) => d.Hours * d.Count);
+ const totalCount = d3.sum(d.group, (d) => d.Count);
+ d.Hours = totalAdjustedHours / totalCount;
+ d.size = totalCount;
+ });
+
+ graph.linkData.forEach((d) => {
+ const totalAdjustedHours = d3.sum(d.group, (d) => d.Hours * d.Count);
+ const totalCount = d3.sum(d.group, (d) => d.Count);
+ d.Hours = totalAdjustedHours / totalCount;
+ d.size = totalCount;
+ });
+
+ colorScale = createColorScale();
- // Add tooltip
graph.nodes.attr("data-bs-toggle", "tooltip").attr("title", (d) => `${d.key}: ${num2(d.Hours)} hours`);
+
graph.links
.attr("data-bs-toggle", "tooltip")
.attr("title", (d) => `${d.source.key} - ${d.target.key}: ${num2(d.Hours)} hours`);
- // Define the color scale
- colorScale = d3
- .scaleLinear()
- .domain(extent)
- .range(["red", "yellow", "green"])
- .interpolate(d3.interpolateLab)
- .clamp(true);
-
- // Style the text labels
graph.texts.attr("fill", "black");
+
colorSankey();
}
function colorSankey() {
- graphs.sankey.nodes.attr("fill", (d) => (d.percentrank > threshold ? colorScale(d.Hours) : "var(--disabled-node)"));
- graphs.sankey.links.attr("fill", (d) => (d.percentrank > threshold ? colorScale(d.Hours) : "var(--disabled-link)"));
+ $thresholdDisplay.textContent = num2(threshold);
+ graphs.sankey.nodes.attr("fill", (d) => colorScale(d.Hours));
+ graphs.sankey.links.attr("fill", (d) => colorScale(d.Hours));
}
$showLinks.addEventListener("change", () => {
@@ -140,14 +303,20 @@ $showLinks.addEventListener("change", () => {
});
$threshold.addEventListener("input", () => {
- threshold = $threshold.value;
+ threshold = parseFloat($threshold.value);
+ colorScale = createColorScale();
colorSankey();
});
function drawNetwork() {
+ const incidents = data.incidents;
+
const serviceStats = d3.rollup(
- filteredIncidents(),
- (v) => ({ TotalHours: d3.sum(v, (d) => d.Hours), Count: v.length }),
+ incidents,
+ (v) => ({
+ TotalHours: d3.sum(v, (d) => d.Hours * d.Count),
+ Count: d3.sum(v, (d) => d.Count),
+ }),
(d) => d.Service
);
@@ -159,179 +328,235 @@ function drawNetwork() {
],
{ count: 1 }
);
- for (const node of nodes) {
+
+ nodes.forEach((node) => {
Object.assign(node, serviceStats.get(node.value) || { TotalHours: 0, Count: 0 });
- node.Hours = node.TotalHours / node.Count;
- }
+ node.Hours = node.TotalHours / node.Count || 0;
+ });
const forces = {
charge: () => d3.forceManyBody().strength(-200),
};
+
const graph = network($network, { nodes, links, forces, d3 });
graphs.network = graph;
+
const rScale = d3
.scaleSqrt()
.domain([0, d3.max(nodes, (d) => d.Count)])
.range([1, 30]);
- // const colorScale = d3.scaleSequential(d3.interpolateRdYlGn).domain([0, d3.max(nodes, (d) => d.Hours)]);
+
graph.nodes
.attr("fill", (d) => colorScale(d.Hours))
.attr("stroke", "white")
.attr("r", (d) => rScale(d.Count))
.attr("data-bs-toggle", "tooltip")
.attr("title", (d) => `${d.value}: ${num2(d.Hours)} hours, ${num0(d.Count)} incidents`);
- graph.links
- .attr("marker-end", "url(#triangle)")
- .attr("stroke", "rgba(var(--bs-body-color-rgb), 0.2)");
+
+ graph.links.attr("marker-end", "url(#triangle)").attr("stroke", "rgba(var(--bs-body-color-rgb), 0.2)");
}
-new bootstrap.Tooltip($sankey, { selector: '[data-bs-toggle="tooltip"]' });
-new bootstrap.Tooltip($network, { selector: '[data-bs-toggle="tooltip"]' });
+new bootstrap.Tooltip($sankey, { selector: "[data-bs-toggle='tooltip']" });
+new bootstrap.Tooltip($network, { selector: "[data-bs-toggle='tooltip']" });
$summarize.addEventListener("click", summarize);
async function summarize() {
- const totalSize = d3.sum(graphs.sankey.nodeData, (d) => d.size);
- function top(data, n, sort, name) {
- return (
- d3
- .sort(data, sort)
- // Skip anything less than 0.5% of the total
- .filter((d) => d.size / totalSize > 0.005)
- .slice(0, n)
- .map((d) => `- ${name(d)}: ${num2(d.Hours)} hrs, ${num0(d.size)} incidents`)
- .join("\n")
- );
+ const selectedServices = filters["Service"].filter((opt) => opt.selected).map((opt) => opt.value);
+ if (selectedServices.length === 0) {
+ $summary.innerHTML = `
+ No services selected for summarization.
+
`;
+ return;
}
- const nodeName = (d) => `${d.cat}=${d.key}`;
- const linkName = (d) => `${d.source.cat}=${d.source.key} & ${d.target.cat}=${d.target.key}`;
- const system = `Identify and suggest specific improvement actions for areas with the highest impact based on incident count, resolution time, and combined metrics.
-
-# Steps
-
-1. **Detailed Data Analysis**: Evaluate the provided incident data across dimensions like area, team, shift, and service.
- - Calculate total outage hours and incident count for each dimension and combination of dimensions.
- - Identify areas, teams, shifts, and services with the highest impact, focusing on both frequency and resolution time.
-
-2. **Root Cause Identification**: Dive into potential root causes behind high-impact areas, teams, or services.
- - Analyze infrastructure dependencies, support processes, staffing adequacy, and cross-team coordination.
- - Identify whether specific tools, services, or operational workflows contribute to extended resolution times or frequent incidents.
-
-3. **Recommend Targeted Improvement Actions**: Suggest actionable interventions based on identified patterns to reduce outages and enhance resolution times.
- - Recommendations should target specific services, teams, shifts, and tools, covering infrastructure upgrades, process improvements, resource adjustments, and targeted training.
- - Clearly differentiate between immediate, short-term, and long-term actions, focusing on feasibility and impact.
-
-# Output Format
-
-Provide a structured analysis with precise, actionable recommendations, categorized by type (infrastructure, process, training, etc.) and timeframe (immediate, short-term, long-term). Use bullet points or numbered lists to enhance clarity.
-
-# Examples
-
-
-Top impact by incident count:
-- Area=North America: 14.94 hrs, 2,043 incidents
-- Team=Internal: 15.44 hrs, 1,615 incidents
-- Shift=Morning: 14.30 hrs, 1,497 incidents
-- Service=Global Market Network Services: 8.68 hrs, 468 incidents
-- Service=Argos: 55.40 hrs, 103 incidents
-
-
-
-## Analysis
-
-- **North America** shows the highest incident count (2,043 incidents) and prolonged resolution times. Likely issues include legacy systems and insufficient support capacity during peak hours.
-- **Internal Team** has significant outage hours (15.44 hrs) across 1,615 incidents, indicating operational inefficiencies or resource shortages, particularly affecting critical services like Argos and Global Market Network Services.
-- **Morning Shift** experiences a high number of incidents (1,497), suggesting challenges in resource management, communication gaps, or lack of proactive monitoring.
-- **Argos** has the longest average resolution time (55.40 hrs), pointing to potential complexity in the system architecture or inadequate technical expertise for incident handling.
-## Common Issues
+ const incidents = filteredIncidents();
-- **Infrastructure Constraints**: Outdated network hardware, low redundancy, and limited capacity in North America.
-- **Team Inefficiencies**: Lack of expertise in handling complex incidents within the Internal Team, particularly for services like Argos and Data Center Network Services.
-- **Process Delays**: Gaps in the incident handover process during Morning and EOD Shifts, leading to delays in triaging and resolution.
+ const serviceData = {};
-## Recommended Actions
+ for (const service of selectedServices) {
+ const serviceIncidents = incidents.filter((d) => d.Service === service);
+ if (serviceIncidents.length === 0) continue;
-### Service-Specific Improvements
-
-- **Argos**:
- - Conduct a detailed architecture review to identify potential bottlenecks.
- - Assign specialized support teams with additional training on Argos-related issues.
- - Implement predictive analytics tools to preemptively detect anomalies.
-
-- **Global Market Network Services**:
- - Enhance network monitoring using tools like SolarWinds or PRTG Network Monitor for real-time alerts.
- - Schedule routine maintenance during low-impact hours to prevent unplanned outages.
-
-### Infrastructure & Staffing Enhancements
-
-- **North America**:
- - Upgrade legacy infrastructure and increase network redundancy.
- - Increase staffing during peak hours, specifically for the Internal Team and EOD shifts, to ensure faster resolution times.
- - Deploy cloud-based solutions to improve scalability and resilience, especially for critical services.
-
-### Process & Tool Improvements
-
-- **Enhanced Incident Management**:
- - Use automated triaging tools like PagerDuty or ServiceNow to prioritize incidents based on severity and impact.
- - Improve handover protocols during shift changes (e.g., Morning and EOD) with streamlined communication tools like Slack or Microsoft Teams integrated with incident management systems.
+ const getStats = (groupByKey) =>
+ d3
+ .rollups(
+ serviceIncidents,
+ (v) => ({
+ Count: d3.sum(v, (d) => d.Count),
+ Hours: d3.sum(v, (d) => d.Hours * d.Count),
+ }),
+ (d) => d[groupByKey]
+ )
+ .map(([key, stats]) => ({
+ Key: key,
+ Count: stats.Count,
+ AvgHours: stats.Hours / stats.Count,
+ }));
+
+ const shiftStats = getStats("Shift");
+ const timeOfDayStats = getStats("Time of Day");
+ const areaStats = getStats("Area");
+ const teamStats = getStats("Team");
+
+ const descriptionStats = d3
+ .rollups(
+ serviceIncidents,
+ (v) => d3.sum(v, (d) => d.Count),
+ (d) => d.DescriptionCleaned
+ )
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 5)
+ .map(([description, count]) => ({
+ Description: description,
+ Count: count,
+ }));
+
+ const relatedServices = data.relations
+ .filter((rel) => rel.Source === service || rel.Target === service)
+ .map((rel) => (rel.Source === service ? rel.Target : rel.Source));
+
+ serviceData[service] = {
+ shiftStats,
+ timeOfDayStats,
+ areaStats,
+ teamStats,
+ descriptionStats,
+ relatedServices,
+ };
+ }
-### Preventive Maintenance & Training
+ const system = `As an expert analyst, provide a concise summary for the selected services, focusing on:
+
+- Problematic times (including specific times of day)
+- Problematic areas
+- Problematic teams
+- Frequent issues or incidents
+- Connections with other services that might have impacted it
+- Recommendations on what can be done
+
+Present the information concisely using bullet points under each service. Ensure that the summary is directly based on the data provided and is actionable.
+
+Example for Output:
+Summary for VaR Service
+
+Problematic Times:
+ Shifts:
+ 06:00-14:00: 27 incidents (Avg 4.04 hrs)
+ 14:00-22:00: 5 incidents (Avg 3.24 hrs)
+ Time of Day:
+ 9 AM: 11 incidents (Avg 3.57 hrs)
+ 10 AM: 5 incidents (Avg 3.68 hrs)
+
+Problematic Areas:
+ Canada: 35 incidents (Avg 3.92 hrs)
+
+Problematic Teams:
+
+CMST: 17 incidents (Avg 3.92 hrs)
+ECA: 17 incidents (Avg 3.92 hrs)
+
+Frequent Issues or Incidents:
+ Delays in VaR reports due to job failures and data issues from:
+ Value job failure (1 occurrence) - Root cause under investigation.
+ Power BI data refresh errors impacting report delays (2 occurrences).
+ Scenario job issues preventing data loading (1 occurrence).
+ Long-running jobs causing delays in data processing (1 occurrence).
+ All value jobs failing in risk run (1 occurrence) - Root cause under investigation.
+
+Connections with Other Services that Might Have Impacted It:
+ GRT
+ CTR
+ Lancelot
+ LOGAN (multiple connections)
+ Anvil
+ K2
+
+Recommendations:
+ Incident Time Optimization: Focus resources and monitoring on the 06:00-14:00 shift, and particularly around 9 AM to 10 AM since these times show the highest rate of incidents.
+ Targeted Team Support: Provide additional support and resource allocation to the CMST and ECA teams to alleviate their incident load.
+ Root Cause Analysis: Conduct a thorough analysis of recurring failures in data processing, especially related to job execution and dependencies on Power BI and other connections.
+ Enhanced Communication: Streamline communication channels between VaR, VaRDevOps, MRM, and all impacted teams to reduce response times for issue triaging.
+ Preventative Measures: Implement monitoring tools that provide early alerts for long-running jobs and data process delays. Regular simulations could also help identify weaknesses in the system before they lead to significant delays.
+`;
-- **Scheduled Maintenance for Data Center Network Services**: Regular preventive checks to reduce incident rates and downtime.
-- **Targeted Training for the Internal Team**: Focus on advanced troubleshooting for services like Argos, utilizing online simulation tools or hands-on workshops.
-- **Proactive Monitoring**: Implement tools like Nagios or Zabbix for early detection of potential failures in services experiencing high incident rates.
+ let message = `Selected Services:\n${selectedServices.join(", ")}\n\n`;
-## Immediate Actions
+ for (const service of selectedServices) {
+ const data = serviceData[service];
+ if (!data) continue;
-- Rapid deployment of additional resources in North America during peak times.
-- Quick assessment of current incident management processes to identify and rectify gaps.
+ message += `Service: ${service}\n`;
-### Short-term Actions
+ const formatStats = (stats, label) =>
+ stats
+ .sort((a, b) => b.Count - a.Count)
+ .slice(0, 2)
+ .map((stat) => `${stat.Key} (${num0(stat.Count)} incidents, Avg ${num2(stat.AvgHours)} hrs)`)
+ .join("; ");
-- Deploy automated monitoring and triaging tools to facilitate faster incident response.
-- Optimize staffing models for shifts with high incident volumes, ensuring adequate coverage and expertise.
+ const shiftInfo = formatStats(data.shiftStats, "Shift");
+ const timeInfo = formatStats(data.timeOfDayStats, "Time of Day");
-### Long-term Actions
+ if (shiftInfo || timeInfo) {
+ message += `- Problematic times: `;
+ if (shiftInfo) message += `Shifts - ${shiftInfo}; `;
+ if (timeInfo) message += `Time of Day - ${timeInfo}`;
+ message += `\n`;
+ }
-- Plan infrastructure overhauls, especially for North America, focusing on network upgrades and cloud integration.
-- Build a culture of continuous improvement through feedback loops, regular training, and better cross-team collaboration.
-
+ const areaInfo = formatStats(data.areaStats, "Area");
+ if (areaInfo) message += `- Problematic areas: ${areaInfo}\n`;
-# Notes
+ const teamInfo = formatStats(data.teamStats, "Team");
+ if (teamInfo) message += `- Problematic teams: ${teamInfo}\n`;
-- Recommendations should be precise, actionable, and aligned with industry best practices in service management and incident reduction.
-- Tailor actions based on the nuances of each area, team, shift, or service, prioritizing high-impact areas and leveraging specific tools mentioned.
-- Aim for holistic improvements that enhance both service stability and team performance.`;
- const message = `
-Top impact by incident count:
-${top(graphs.sankey.nodeData, 10, (d) => -d.size, nodeName)}
+ if (data.descriptionStats.length > 0) {
+ message += `- Frequent issues: `;
+ message += data.descriptionStats
+ .map((desc) => `${desc.Description} (${num0(desc.Count)} occurrences)`)
+ .join("; ");
+ message += `\n`;
+ }
-Top impact by time to resolve each incident:
-${top(graphs.sankey.nodeData, 10, (d) => -d.Hours, nodeName)}
+ if (data.relatedServices.length > 0) {
+ message += `- Impacting connections: ${data.relatedServices.join(", ")}\n`;
+ } else {
+ message += `- Impacting connections: None\n`;
+ }
-Top combined impact by incident count:
-${top(graphs.sankey.linkData, 10, (d) => -d.size, linkName)}
+ message += `\n`;
+ }
-Top combined impact by time to resolve each incident:
-${top(graphs.sankey.linkData, 10, (d) => -d.Hours, linkName)}
-`;
$summary.innerHTML = /* html */ `
Loading...
`;
- for await (const { content } of asyncLLM("https://llmfoundry.straive.com/openai/v1/chat/completions", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({
- model: "gpt-4o-mini",
- stream: true,
- messages: [
- { role: "system", content: system },
- { role: "user", content: message },
- ],
- }),
- })) {
- if (content) $summary.innerHTML = marked.parse(content);
+
+ let fullContent = "";
+
+ try {
+ for await (const { content } of asyncLLM("https://llmfoundry.straive.com/openai/v1/chat/completions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({
+ model: "gpt-4o-mini",
+ stream: true,
+ messages: [
+ { role: "system", content: system },
+ { role: "user", content: message },
+ ],
+ }),
+ })) {
+ if (content) {
+ fullContent = content;
+ $summary.innerHTML = marked.parse(fullContent);
+ }
+ }
+ } catch (error) {
+ console.error("Error in summarize function:", error);
+ $summary.innerHTML = /* html */`
+ An error occurred while generating the summary: ${error.message}
+
`;
}
}
diff --git a/services.html b/services.html
new file mode 100644
index 0000000..df097cd
--- /dev/null
+++ b/services.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+ Services Chainflow
+
+
+
+
+
+
+
+
+
+
+
+
+
Service Chainflow
+
What are the main relations between services?
+
+
+
This application is designed for incident analysis, allowing users to upload CSV files containing Service Node data and Service Links data.
+
It visualizes the relationships between different Services and their impacts using Network Flow Diagram.
+
You can use sample data from this folder if you have access.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services.js b/services.js
new file mode 100644
index 0000000..801e46b
--- /dev/null
+++ b/services.js
@@ -0,0 +1,200 @@
+class SupplyChainViz {
+ constructor() {
+ this.nodes = [];
+ this.links = [];
+ this.hoveredNode = null;
+
+ this.STAGES = ["Node 1", "Node 2", "Node 3", "Node 4", "Node 5"];
+ this.STAGE_COLORS = {
+ 0: "#4299E1",
+ 1: "#ED8936",
+ 2: "#48BB78",
+ 3: "#E53E3E",
+ 4: "#805AD5",
+ };
+
+ this.STAGE_SPACING = 200;
+ this.NODE_SPACING = 70;
+ this.MARGIN = 60;
+ this.MAX_NODE_SIZE = 20;
+ this.MIN_NODE_SIZE = 8;
+
+ this.svg = document.getElementById("chart");
+ this.setupEventListeners();
+ }
+
+ setupEventListeners() {
+ document.getElementById("nodesFile").addEventListener("change", (e) => this.handleNodesFile(e));
+ document.getElementById("linksFile").addEventListener("change", (e) => this.handleLinksFile(e));
+ }
+
+ async handleNodesFile(event) {
+ const file = event.target.files[0];
+ if (file) {
+ Papa.parse(file, {
+ header: true,
+ dynamicTyping: true,
+ complete: (results) => {
+ this.nodes = results.data.filter((node) => node.id);
+ if (this.links.length > 0) {
+ this.render();
+ }
+ },
+ });
+ }
+ }
+
+ async handleLinksFile(event) {
+ const file = event.target.files[0];
+ if (file) {
+ Papa.parse(file, {
+ header: true,
+ dynamicTyping: true,
+ complete: (results) => {
+ this.links = results.data.filter((link) => link.source && link.target);
+ if (this.nodes.length > 0) {
+ this.render();
+ }
+ },
+ });
+ }
+ }
+
+ getNodePosition(stage, position) {
+ return {
+ x: this.MARGIN + stage * this.STAGE_SPACING,
+ y: this.MARGIN + position * this.NODE_SPACING,
+ };
+ }
+
+ getNodeSize(value) {
+ if (!value) return this.MIN_NODE_SIZE;
+ const maxValue = Math.max(...this.nodes.map((n) => n.value || 0));
+ const minValue = Math.min(...this.nodes.map((n) => n.value || 0));
+ const scale = (value - minValue) / (maxValue - minValue);
+ return this.MIN_NODE_SIZE + scale * (this.MAX_NODE_SIZE - this.MIN_NODE_SIZE);
+ }
+
+ createPath(sourceNode, targetNode) {
+ const source = this.getNodePosition(sourceNode.stage, sourceNode.position);
+ const target = this.getNodePosition(targetNode.stage, targetNode.position);
+ const midX = (source.x + target.x) / 2;
+
+ return `M ${source.x} ${source.y}
+ C ${midX} ${source.y},
+ ${midX} ${target.y},
+ ${target.x} ${target.y}`;
+ }
+
+ getLinkWidth(value) {
+ const maxValue = Math.max(...this.links.map((l) => l.value || 0));
+ const minValue = Math.min(...this.links.map((l) => l.value || 0));
+ const scale = (value - minValue) / (maxValue - minValue);
+ return 2 + scale * 6;
+ }
+
+ render() {
+ // Clear previous content
+ this.svg.innerHTML = "";
+
+ // Add stage labels
+ this.STAGES.forEach((stage, index) => {
+ const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ text.setAttribute("x", this.MARGIN + index * this.STAGE_SPACING);
+ text.setAttribute("y", 20);
+ text.setAttribute("text-anchor", "middle");
+ text.classList.add("node-label");
+ text.textContent = stage;
+ this.svg.appendChild(text);
+ });
+
+ // Add links
+ this.links.forEach((link) => {
+ const sourceNode = this.nodes.find((n) => n.id === link.source);
+ const targetNode = this.nodes.find((n) => n.id === link.target);
+ if (!sourceNode || !targetNode) return;
+
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ path.setAttribute("d", this.createPath(sourceNode, targetNode));
+ path.setAttribute("fill", "none");
+ path.setAttribute("stroke", this.STAGE_COLORS[sourceNode.stage]);
+ path.setAttribute("stroke-width", this.getLinkWidth(link.value));
+ path.setAttribute("opacity", "0.6");
+ path.classList.add("link");
+ path.dataset.source = link.source;
+ path.dataset.target = link.target;
+ path.dataset.value = link.value;
+ this.svg.appendChild(path);
+ });
+
+ // Add nodes
+ this.nodes.forEach((node) => {
+ const pos = this.getNodePosition(node.stage, node.position);
+ const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ group.setAttribute("transform", `translate(${pos.x},${pos.y})`);
+ group.classList.add("node");
+
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ circle.setAttribute("r", this.getNodeSize(node.value));
+ circle.setAttribute("fill", this.STAGE_COLORS[node.stage]);
+ circle.setAttribute("opacity", "0.8");
+
+ const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ label.setAttribute("y", -this.getNodeSize(node.value) - 5);
+ label.setAttribute("text-anchor", "middle");
+ label.classList.add("node-label");
+ label.textContent = node.id;
+
+ group.appendChild(circle);
+ group.appendChild(label);
+
+ // Add hover events
+ group.addEventListener("mouseenter", () => this.handleNodeHover(node.id));
+ group.addEventListener("mouseleave", () => this.handleNodeHover(null));
+
+ this.svg.appendChild(group);
+ });
+ }
+
+ handleNodeHover(nodeId) {
+ this.hoveredNode = nodeId;
+
+ // Update links visibility
+ const links = this.svg.querySelectorAll(".link");
+ links.forEach((link) => {
+ if (!nodeId) {
+ link.setAttribute("opacity", "0.6");
+ } else if (link.dataset.source === nodeId || link.dataset.target === nodeId) {
+ link.setAttribute("opacity", "0.8");
+ } else {
+ link.setAttribute("opacity", "0.01");
+ }
+ });
+
+ // Update value labels
+ const valueLabels = this.svg.querySelectorAll(".value-label");
+ valueLabels.forEach((label) => label.remove());
+
+ if (nodeId) {
+ const relevantLinks = this.links.filter((link) => link.source === nodeId || link.target === nodeId);
+
+ relevantLinks.forEach((link) => {
+ const sourceNode = this.nodes.find((n) => n.id === link.source);
+ const targetNode = this.nodes.find((n) => n.id === link.target);
+ const source = this.getNodePosition(sourceNode.stage, sourceNode.position);
+ const target = this.getNodePosition(targetNode.stage, targetNode.position);
+
+ const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ label.setAttribute("x", (source.x + target.x) / 2);
+ label.setAttribute("y", (source.y + target.y) / 2 - 10);
+ label.setAttribute("text-anchor", "middle");
+ label.classList.add("value-label");
+ label.textContent = `${link.value.toLocaleString()}`;
+ this.svg.appendChild(label);
+ });
+ }
+ }
+}
+
+// Initialize visualization
+const viz = new SupplyChainViz();
diff --git a/style.css b/style.css
index 487a153..3d0d5af 100644
--- a/style.css
+++ b/style.css
@@ -20,3 +20,95 @@
#summary li p {
margin-bottom: 0;
}
+
+/* Dropdown styles */
+.dropdown-menu {
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.dropdown-search {
+ position: sticky;
+ top: 0;
+ background: var(--bs-body-bg);
+ padding: 8px;
+ border-bottom: 1px solid var(--bs-border-color);
+}
+
+.dropdown-search input {
+ width: 100%;
+ padding: 4px 8px;
+ border: 1px solid var(--bs-border-color);
+ border-radius: 4px;
+}
+
+.dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ padding: 0.5rem 1rem;
+ user-select: none;
+}
+
+.dropdown-item label {
+ cursor: pointer;
+ margin-bottom: 0;
+ width: 100%;
+}
+
+.dropdown-item:hover {
+ background-color: var(--bs-tertiary-bg);
+}
+
+/* Prevent dropdown from closing when clicking inside */
+.dropdown-menu.show {
+ display: block;
+}
+
+/* Prevent text selection */
+.dropdown-item,
+.dropdown-item label {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+/* -------------------------------------------------- */
+/* services.html */
+.visualization {
+ border: 1px solid var(--bs-border-color);
+ border-radius: 8px;
+ padding: 20px;
+ margin: 20px auto;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 90%;
+ max-width: 1400px;
+ height: calc(100vh - 400px);
+ min-height: 500px;
+ overflow: hidden;
+}
+
+#chart {
+ width: 100%;
+ height: 100%;
+ display: block;
+ margin: auto;
+}
+
+#upload {
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.node-label {
+ font-size: 12px;
+ pointer-events: none;
+}
+.value-label {
+ font-size: 10px;
+ pointer-events: none;
+}