簡體   English   中英

D3:緩慢的可縮放熱圖

[英]D3: slow zoomable heatmap

我有這個可縮放的熱圖,在放大或縮小時看起來太慢了。 是否有任何東西可以使它更快/更順暢,或者只是太多點,這是我能擁有的最好的。 我想知道是否有一些技巧可以讓瀏覽器更輕松,同時保持工具提示等增強功能。 或者我處理縮放功能的代碼也不是很好。

 <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <style> .axis text { font: 10px sans-serif; } .axis path, .axis line { fill: none; stroke: #000000; } .x.axis path { //display: none; } .chart rect { fill: steelblue; } .chart text { fill: white; font: 10px sans-serif; text-anchor: end; } #tooltip { position:absolute; background-color: #2B292E; color: white; font-family: sans-serif; font-size: 15px; pointer-events: none; /*dont trigger events on the tooltip*/ padding: 15px 20px 10px 20px; text-align: center; opacity: 0; border-radius: 4px; } </style> <title>Bar Chart</title> <!-- Reference style.css --> <!-- <link rel="stylesheet" type="text/css" href="style.css">--> <!-- Reference minified version of D3 --> <script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script> <script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script> </head> <body> <div id="chart" style="width: 700px; height: 500px"></div> <script> var dataset = []; for (let i = 1; i < 360; i++) { for (j = 1; j < 75; j++) { dataset.push({ day: i, hour: j, tOutC: Math.random() * 25, }) } }; var days = d3.max(dataset, function(d) { return d.day; }) - d3.min(dataset, function(d) { return d.day; }); var hours = d3.max(dataset, function(d) { return d.hour; }) - d3.min(dataset, function(d) { return d.hour; }); var tMin = d3.min(dataset, function(d) { return d.tOutC; }), tMax = d3.max(dataset, function(d) { return d.tOutC; }); var dotWidth = 1, dotHeight = 3, dotSpacing = 0.5; var margin = { top: 0, right: 25, bottom: 40, left: 25 }, width = (dotWidth * 2 + dotSpacing) * days, height = (dotHeight * 2 + dotSpacing) * hours; var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C']; var xScale = d3.scaleLinear() .domain(d3.extent(dataset, function(d){return d.day})) .range([0, width]); var yScale = d3.scaleLinear() .domain(d3.extent(dataset, function(d){return d.hour})) .range([(dotHeight * 2 + dotSpacing) * hours, dotHeight * 2 + dotSpacing]); var colorScale = d3.scaleQuantile() .domain([0, colors.length - 1, d3.max(dataset, function(d) { return d.tOutC; })]) .range(colors); var xAxis = d3.axisBottom().scale(xScale); // Define Y axis var yAxis = d3.axisLeft().scale(yScale); var zoom = d3.zoom() .scaleExtent([dotWidth, dotHeight]) .translateExtent([ [80, 20], [width, height] ]) .on("zoom", zoomed); var tooltip = d3.select("body").append("div") .attr("id", "tooltip") .style("opacity", 0); // SVG canvas var svg = d3.select("#chart") .append("svg") .attr("width", width + margin.left + margin.right) .attr("height", height + margin.top + margin.bottom) .call(zoom) .append("g") .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); // Clip path svg.append("clipPath") .attr("id", "clip") .append("rect") .attr("width", width) .attr("height", height); // Heatmap dots svg.append("g") .attr("clip-path", "url(#clip)") .selectAll("ellipse") .data(dataset) .enter() .append("ellipse") .attr("cx", function(d) { return xScale(d.day); }) .attr("cy", function(d) { return yScale(d.hour); }) .attr("rx", dotWidth) .attr("ry", dotHeight) .attr("fill", function(d) { return colorScale(d.tOutC); }) .on("mouseover", function(d){ $("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100); var xpos = d3.event.pageX +10; var ypos = d3.event.pageY +20; $("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1); }).on("mouseout", function(){ $("#tooltip").animate({duration: 500}).css("opacity",0); }); //Create X axis var renderXAxis = svg.append("g") .attr("class", "x axis") .attr("transform", "translate(0," + yScale(0) + ")") .call(xAxis) //Create Y axis var renderYAxis = svg.append("g") .attr("class", "y axis") .call(yAxis); function zoomed() { // update: rescale x axis renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale))); update(); } function update() { // update: cache rescaleX value var rescaleX = d3.event.transform.rescaleX(xScale); svg.selectAll("ellipse") .attr('clip-path', 'url(#clip)') // update: apply rescaleX value .attr("cx", function(d) { return rescaleX(d.day); }) // .attr("cy", function(d) { // return yScale(d.hour); // }) // update: apply rescaleX value .attr("rx", function(d) { return (dotWidth * d3.event.transform.k); }) .attr("fill", function(d) { return colorScale(d.tOutC); }); } </script> </body> </html> 

謝謝

解決方案不是更新縮放的所有點,而是將縮放變換應用於包含點的組。 需要在額外的父g heatDotsGroup上完成組的heatDotsGroup

使用正則表達式替換來處理y的縮放比例(將其固定為1),通過將transform.y設置為0來限制平移,並根據當前比例限制x的平移。

允許稍微翻譯過去0以顯示放大時的第一個點完成。

    var zoom = d3.zoom()
        .scaleExtent([dotWidth, dotHeight])
        .on("zoom", zoomed);

    // Heatmap dots
    var heatDotsGroup = svg.append("g")
        .attr("clip-path", "url(#clip)")
        .append("g");

    heatDotsGroup.selectAll("ellipse")
        .data(dataset)
        .enter()
        .append("ellipse")
        .attr("cx", function(d) { return xScale(d.day); })
        .attr("cy", function(d) { return yScale(d.hour); })
        .attr("rx", dotWidth)
        .attr("ry", dotHeight)
        .attr("fill", function(d) { return colorScale(d.tOutC); })
        .on("mouseover", function(d){
            $("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
            var xpos = d3.event.pageX +10;
            var ypos = d3.event.pageY +20;
            $("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
        }).on("mouseout", function(){
            $("#tooltip").animate({duration: 500}).css("opacity",0);
        }); 

    function zoomed() {
        d3.event.transform.y = 0;
        d3.event.transform.x = Math.min(d3.event.transform.x, 5);
        d3.event.transform.x = Math.max(d3.event.transform.x, (1-d3.event.transform.k) * width );

        // update: rescale x axis
        renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));

        heatDotsGroup.attr("transform", d3.event.transform.toString().replace(/scale\((.*?)\)/, "scale($1, 1)"));
    }

嘗試畫布

你有27 000個節點。 這可能是SVG性能下降最多的時候,Canvas開始真正發光。 當然,Canvas不像SVG那樣有狀態,它只是像素,沒有很好的元素可以在DOM中鼠標懸停並告訴你它們在哪里和它們是什么。 但是,有辦法解決這個缺點,以便我們保持速度和互動能力。

對於使用您的代碼段的初始渲染,我的平均渲染時間約為440毫秒。 但是,通過畫布的魔力,我可以渲染相同的熱圖,平均渲染時間約為103毫秒。 這些節省可以應用於縮放,動畫等。

對於像橢圓這樣的非常小的東西,存在使用canvas而不是SVG更難修復別名問題的風險,盡管每個瀏覽器呈現的方式會有所不同

設計意義

使用Canvas,我們可以像SVG一樣保留進入/退出/更新周期,但我們也可以選擇刪除它。 在次進入/退出/更新周期對非常好,用帆布:轉換,動態數據,heirarcical數據等,我以前花了一些時間對某些與問候D3 Canvas和SVG之間的更高水平的差異在這里

對於我在這里的回答,我們將離開進入周期。 當我們想要更新可視化時,我們只是根據數據數組本身重繪所有內容。

繪制熱圖

我為了簡潔起見使用矩形。 Canvas的橢圓方法尚未准備好,但您可以輕松地模擬它

我們需要一個繪制數據集的函數。 如果您將x / y /顏色硬編碼到數據集中,我們可以使用非常簡單的:

function drawNodes()
  dataset.forEach(function(d) {
    ctx.beginPath();
    ctx.rect(d.x,d.y,width,height);
    ctx.fillStyle = d.color;
    ctx.fill(); 
  })    
}

但我們需要縮放您的值,計算顏色,我們應該應用縮放。 我最后得到了一個相對簡單的:

function drawNodes()
  var k = d3.event ? d3.event.transform.k : 1;
  var dw = dotWidth * k;
  ctx.clearRect(0,0,width,height);      // erase what's there
  dataset.forEach(function(d) {
    var x = xScale(d.day);
    var y = yScale(d.hour);
    var fill = colorScale(d.tOutC);
    ctx.beginPath();
    ctx.rect(x,y,dw,dotHeight);
    ctx.fillStyle = fill;
    ctx.strokeStyle = fill;
    ctx.stroke();
    ctx.fill(); 
  })    
}

這可以用於初始繪制節點(當未定義d3.event時),或用於縮放/平移事件(之后每次調用此函數)。

軸怎么樣?

d3軸用於SVG。 所以,我剛剛疊加了一個Canvas元素的SVG頂層,在上層SVG中定位絕對和禁用鼠標事件。

說到軸,我只有一個繪圖功能(更新/初始繪圖之間沒有區別),所以我使用參考x刻度和get get的渲染x刻度,而不是在更新函數中創建一次性重新縮放的x刻度

現在我有一個畫布,我該如何與它交互?

我們可以使用一些像素位置並將其轉換為特定數據的方法:

  • 使用Voronoi圖(使用.find方法定位基准)
  • 使用Force布局(也使用.find方法定位基准)
  • 使用隱藏的畫布(使用像素顏色表示基准索引)
  • 使用比例的反轉功能(當數據被網格化時)

第三個選項可能是最常見的選項之一,雖然前兩個選項看起來相似,但查找方法在內部確實不同(voronoi鄰居vs四叉樹)。 在這種情況下,最后一種方法非常合適:我們有一個數據網格,我們可以反轉鼠標坐標來獲取行和列數據。 根據您的代碼段看起來像:

function mousemove() {
  var xy = d3.mouse(this);
  var x = Math.round(xScale.invert(xy[0]));
  var y = Math.round(yScale.invert(xy[1]));
  // For rounding on canvas edges:
  if(x > xScaleRef.domain()[1]) x = xScaleRef.domain()[1];
  if(x < xScaleRef.domain()[0]) x = xScaleRef.domain()[0];
  if(y > yScale.domain()[1]) y = yScale.domain()[1];
  if(y < yScale.domain()[0]) y = yScale.domain()[0];

  var index = --x*74 + y-1;  // minus ones for non zero indexed x,y values.
  var d = dataset[index];
  console.log(x,y,index,d)

  $("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
  var xpos = d3.event.pageX +10;
  var ypos = d3.event.pageY +20;
  $("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}

*我使用了mousemove,因為mouseover將在畫布上移動時觸發一次,我們需要不斷更新,如果我們想要隱藏工具提示,我們可以檢查選擇的像素是否為白色:

var p = ctx.getImageData(xy[0], xy[1], 1, 1).data; // pixel data:
if (!p[0] && !p[1] && !p[2])   {  /* show tooltip */ }
else {  /* hide tooltip */ }

我已經明確提到了上面的大部分更改,但我在下面做了一些額外的更改。 首先,我需要選擇畫布,定位它,獲取上下文等等。我也交換了橢圓形,所以定位有點不同(但是你使用線性刻度(橢圓形質心)還有其他定位問題我可以按原樣落在svg的邊緣上,我沒有修改它來考慮橢圓/ rects的寬度/高度。這個比例問題遠遠不是我沒有修改它的問題。

 var dataset = []; for (let i = 1; i < 360; i++) { for (j = 1; j < 75; j++) { dataset.push({ day: i, hour: j, tOutC: Math.random() * 25, }) } }; var days = d3.max(dataset, function(d) { return d.day; }) - d3.min(dataset, function(d) { return d.day; }); var hours = d3.max(dataset, function(d) { return d.hour; }) - d3.min(dataset, function(d) { return d.hour; }); var tMin = d3.min(dataset, function(d) { return d.tOutC; }), tMax = d3.max(dataset, function(d) { return d.tOutC; }); var dotWidth = 1, dotHeight = 3, dotSpacing = 0.5; var margin = { top: 20, right: 25, bottom: 40, left: 25 }, width = (dotWidth * 2 + dotSpacing) * days, height = (dotHeight * 2 + dotSpacing) * hours; var tooltip = d3.select("body").append("div") .attr("id", "tooltip") .style("opacity", 0); var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C']; var xScale = d3.scaleLinear() .domain(d3.extent(dataset, function(d){return d.day})) .range([0, width]); var xScaleRef = xScale.copy(); var yScale = d3.scaleLinear() .domain(d3.extent(dataset, function(d){return d.hour})) .range([height,0]); var colorScale = d3.scaleQuantile() .domain([0, colors.length - 1, d3.max(dataset, function(d) { return d.tOutC; })]) .range(colors); var xAxis = d3.axisBottom().scale(xScale); var yAxis = d3.axisLeft().scale(yScale); var zoom = d3.zoom() .scaleExtent([dotWidth, dotHeight]) .translateExtent([ [0,0], [width, height] ]) .on("zoom", zoomed); var tooltip = d3.select("body").append("div") .attr("id", "tooltip") .style("opacity", 0); // SVG & Canvas: var canvas = d3.select("#chart") .append("canvas") .attr("width", width) .attr("height", height) .style("left", margin.left + "px") .style("top", margin.top + "px") .style("position","absolute") .on("mousemove", mousemove) .on("mouseout", mouseout); var svg = d3.select("#chart") .append("svg") .attr("width", width + margin.left + margin.right) .attr("height", height + margin.top + margin.bottom) .append("g") .attr("transform","translate("+[margin.left,margin.top]+")"); var ctx = canvas.node().getContext("2d"); canvas.call(zoom); // Initial Draw: drawNodes(dataset); //Create Axes: var renderXAxis = svg.append("g") .attr("class", "x axis") .attr("transform", "translate(0," + yScale(0) + ")") .call(xAxis) var renderYAxis = svg.append("g") .attr("class", "y axis") .call(yAxis); // Handle Zoom: function zoomed() { // rescale the x Axis: xScale = d3.event.transform.rescaleX(xScaleRef); // Use Reference Scale. // Redraw the x Axis: renderXAxis.call(xAxis.scale(xScale)); // Clear and redraw the nodes: drawNodes(); } // Draw nodes: function drawNodes() { var k = d3.event ? d3.event.transform.k : 1; var dw = dotWidth * k; ctx.clearRect(0,0,width,height); dataset.forEach(function(d) { var x = xScale(d.day); var y = yScale(d.hour); var fill = colorScale(d.tOutC); ctx.beginPath(); ctx.rect(x,y,dw,dotHeight); ctx.fillStyle = fill; ctx.strokeStyle = fill; ctx.stroke(); ctx.fill(); }) } // Mouse movement: function mousemove() { var xy = d3.mouse(this); var x = Math.round(xScale.invert(xy[0])); var y = Math.round(yScale.invert(xy[1])); if(x > xScaleRef.domain()[1]) x = xScaleRef.domain()[1]; if(x < xScaleRef.domain()[0]) x = xScaleRef.domain()[0]; if(y > yScale.domain()[1]) y = yScale.domain()[1]; if(y < yScale.domain()[0]) y = yScale.domain()[0]; var index = --x*74 + y-1; // minus ones for non zero indexed x,y values. var d = dataset[index]; $("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100); var xpos = d3.event.pageX +10; var ypos = d3.event.pageY +20; $("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1); } function mouseout() { $("#tooltip").animate({duration: 500}).css("opacity",0); }; 
 .axis text { font: 10px sans-serif; } .axis path, .axis line { fill: none; stroke: #000000; } .x.axis path { //display: none; } .chart rect { fill: steelblue; } .chart text { fill: white; font: 10px sans-serif; text-anchor: end; } #tooltip { position:absolute; background-color: #2B292E; color: white; font-family: sans-serif; font-size: 15px; pointer-events: none; /*dont trigger events on the tooltip*/ padding: 15px 20px 10px 20px; text-align: center; opacity: 0; border-radius: 4px; } svg { position: absolute; top: 0; left:0; pointer-events: none; } 
 <script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script> <script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script> <div id="chart" style="width: 700px; height: 500px"></div> 

以下所有組合建議的結果並不完美,但主觀上略好一些:

 <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <style> .axis text { font: 10px sans-serif; } .axis path, .axis line { fill: none; stroke: #000000; } .x.axis path { //display: none; } .chart rect { fill: steelblue; } .chart text { fill: white; font: 10px sans-serif; text-anchor: end; } #tooltip { position:absolute; background-color: #2B292E; color: white; font-family: sans-serif; font-size: 15px; pointer-events: none; /*dont trigger events on the tooltip*/ padding: 15px 20px 10px 20px; text-align: center; opacity: 0; border-radius: 4px; } </style> <title>Bar Chart</title> <!-- Reference style.css --> <!-- <link rel="stylesheet" type="text/css" href="style.css">--> <!-- Reference minified version of D3 --> <script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script> <script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script> </head> <body> <div id="chart" style="width: 700px; height: 500px"></div> <script> var dataset = []; for (let i = 1; i < 360; i++) { for (j = 1; j < 75; j++) { dataset.push({ day: i, hour: j, tOutC: Math.random() * 25, }) } }; var days = d3.max(dataset, function(d) { return d.day; }) - d3.min(dataset, function(d) { return d.day; }); var hours = d3.max(dataset, function(d) { return d.hour; }) - d3.min(dataset, function(d) { return d.hour; }); var tMin = d3.min(dataset, function(d) { return d.tOutC; }), tMax = d3.max(dataset, function(d) { return d.tOutC; }); var dotWidth = 1, dotHeight = 3, dotSpacing = 0.5; var margin = { top: 0, right: 25, bottom: 40, left: 25 }, width = (dotWidth * 2 + dotSpacing) * days, height = (dotHeight * 2 + dotSpacing) * hours; var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C']; var xScale = d3.scaleLinear() .domain(d3.extent(dataset, function(d){return d.day})) .range([0, width]); var yScale = d3.scaleLinear() .domain(d3.extent(dataset, function(d){return d.hour})) .range([(dotHeight * 2 + dotSpacing) * hours, dotHeight * 2 + dotSpacing]); var colorScale = d3.scaleQuantile() .domain([0, colors.length - 1, d3.max(dataset, function(d) { return d.tOutC; })]) .range(colors); var xAxis = d3.axisBottom().scale(xScale); // Define Y axis var yAxis = d3.axisLeft().scale(yScale); var zoom = d3.zoom() .scaleExtent([dotWidth, dotHeight]) .translateExtent([ [80, 20], [width, height] ]) // .on("zoom", zoomed); .on("end", zoomed); var tooltip = d3.select("body").append("div") .attr("id", "tooltip") .style("opacity", 0); // SVG canvas var svg = d3.select("#chart") .append("svg") .attr("width", width + margin.left + margin.right) .attr("height", height + margin.top + margin.bottom) .call(zoom) .append("g") .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); // Clip path svg.append("clipPath") .attr("id", "clip") .append("rect") .attr("width", width) .attr("height", height); // Heatmap dots svg.append("g") .attr("clip-path", "url(#clip)") .selectAll("ellipse") .data(dataset) .enter() .append("ellipse") .attr("cx", function(d) { return xScale(d.day); }) .attr("cy", function(d) { return yScale(d.hour); }) .attr("rx", dotWidth) .attr("ry", dotHeight) .attr("fill", function(d) { return colorScale(d.tOutC); }) .on("mouseover", function(d){ $("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100); var xpos = d3.event.pageX +10; var ypos = d3.event.pageY +20; $("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1); }).on("mouseout", function(){ $("#tooltip").animate({duration: 500}).css("opacity",0); }); //Create X axis var renderXAxis = svg.append("g") .attr("class", "x axis") .attr("transform", "translate(0," + yScale(0) + ")") .call(xAxis) //Create Y axis var renderYAxis = svg.append("g") .attr("class", "y axis") .call(yAxis); function zoomed() { // update: rescale x axis renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale))); update(); } function update() { // update: cache rescaleX value var rescaleX = d3.event.transform.rescaleX(xScale); var scaledRadius = dotWidth * d3.event.transform.k; var scaledCxes = [...Array(360).keys()].map(i => rescaleX(i)); svg.selectAll("ellipse") // .attr('clip-path', 'url(#clip)') // update: apply rescaleX value .attr("cx", d => scaledCxes[d.day]) // .attr("cy", function(d) { // return yScale(d.hour); // }) // update: apply rescaleX value .attr("rx", scaledRadius) // .attr("fill", function(d) { // return colorScale(d.tOutC); // }); } </script> </body> </html> 

  • 使用on("end", zoomed)而不是on("zoom", zoomed)

我們可以嘗試的第一件事是僅在縮放事件結束時激活縮放更改,以便在單個縮放事件期間不會跳過這些非確定性更新。 它可以降低所需的處理效果,因為只進行了一次計算,它消除了全局跳躍的不適:

var zoom = d3.zoom()
  .scaleExtent([dotWidth, dotHeight])
  .translateExtent([ [80, 20], [width, height] ])
  .on("end", zoomed); // instead of .on("zoom", zoomed);
  • 刪除縮放期間保持相同的更新:

我們還可以從節點中刪除保持相同的內容,例如在縮放期間保持相同的圓的顏色.attr("fill", function(d) { return colorScale(d.tOutC); }); .attr('clip-path', 'url(#clip)')

  • 只計算一次使用過幾次:

縮放后的新圓半徑只能計算一次而不是27K次,因為它對所有圓都是相同的:

var scaledRadius = dotWidth * d3.event.transform.k;

.attr("rx", scaledRadius)

對於x位置,我們可以按照每個可能的x值(360次)計算一次,並將其存儲在一個數組中,以便在恆定時間內訪問它們,而不是計算它27K次:

var scaledCxes = [...Array(360).keys()].map(i => rescaleX(i));

.attr("cx", d => scaledCxes[d.day])
  • 最后一個顯而易見的選擇是減少節點數量,因為它是問題的根源!

  • 如果縮放范圍會更大,我還建議過濾節點不再可見。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM