在本地读取数据后,Vue页面最初无法呈现

时间:2019-02-05 13:11:38

标签: javascript html json d3.js vue.js

背景

我想使用一个基于JavaScript的图表,名为“ Dependency Graph using D3 + Vue.js (3/3)”。网页显示了使用HTML和JavaScript并使用D3和Vue框架编写的图形,该图形由一个HTML页面和三个JSON数据文件组成。客户端HTML从Web服务器动态下载三个JSON数据文件,然后将其用于在任何给定时间显示三个图形之一。用户可以单击“更改数据”按钮以随机显示三个数据集之一。

enter image description here

出于技术原因,我需要在浏览器的完全静态的本地HTML页面中显示图形(即,不使用远程或本地Web服务器)。当我将index.html和在该页面上找到的三个JSON文件下载到单个本地文件夹并打开index.html页面时,可以理解地收到错误消息“ Fetch API无法加载。URL方案必须为” http“或” https“用于CORS请求”:

enter image description here

可以看出,同源性安全策略默认禁止某些cross-origin resource sharing(CORS)请求,尤其是Ajax / XHR请求。在我们的例子中,图的JavaScript代码尝试使用d3.json函数调用打开本地JSON文件。由于文件是本地文件,因此d3.json使用file URL方案,而不是httphttps,这违反了权限策略。

临时解决方案

解决方案应该是一个简单的解决方案:与其使用d3.json之类的XHR函数,不如将JSON文件转换为脚本中的嵌入式字符串,然后解析这些字符串以创建JSON对象。这就是我所做的:我如下更改了index.html中的JavaScript代码:

  • 将JSON文件嵌入字符串
  • 修改脚本以使用这些JSON字符串变量
  • 解析JSON字符串,在需要的地方传递JSON对象

我修改的index.html

<!DOCTYPE html>
<meta charset="utf-8">
<head>
  <script src="https://cdn.jsdelivr.net/npm/vue"></script>
  <script src="https://d3js.org/d3.v5.min.js"></script>
</head>
<body>
  <div id="app">
    <dependency-graph :data="data"></dependency-graph>
    <div style="padding-top: 10px; text-align: center" @click="changeData()">
      <button>Change Data</button>
    </div>
  </div>
  <script>
  var data1 = '{ "nodes": [ { "name": "firmware", "group": 1, "class": "system" }, { "name": "loader", "group": 1, "class": "system" }, { "name": "kernel", "group": 1, "class": "system" }, { "name": "systemd", "group": 1, "class": "mount" }, { "name": "mount", "group": 2, "class": "mount" }, { "name": "init.scope", "group": 1, "class": "init" }, { "name": "system.slice", "group": 1, "class": "init" }, { "name": "system-getty.slice", "group": 1, "class": "init" }, { "name": "systemd-initctl.socker", "group": 1, "class": "init" }, { "name": "tmp.mount", "group": 1, "class": "init" }, { "name": "sys-devices", "group": 2, "class": "init" }, { "name": "boot.mount", "group": 2, "class": "init" } ], "links": [ { "source": 1, "target": 0, "value": 1, "type": "depends" }, { "source": 2, "target": 1, "value": 8, "type": "depends" }, { "source": 3, "target": 2, "value": 6, "type": "depends" }, { "source": 4, "target": 3, "value": 1, "type": "needs" }, { "source": 5, "target": 3, "value": 1, "type": "needs" }, { "source": 6, "target": 3, "value": 1, "type": "needs" }, { "source": 7, "target": 3, "value": 1, "type": "needs" }, { "source": 8, "target": 3, "value": 2, "type": "needs" }, { "source": 9, "target": 3, "value": 1, "type": "needs" }, { "source": 11, "target": 10, "value": 1, "type": "depends" }, { "source": 11, "target": 3, "value": 3, "type": "depends" }, { "source": 11, "target": 2, "value": 3, "type": "depends" }, { "source": 11, "target": 3, "value": 5, "type": "needs" } ]}';
  var data2 = '{ "nodes": [ { "name": "firmware", "group": 1, "class": "system" }, { "name": "loader", "group": 1, "class": "system" }, { "name": "kernel", "group": 1, "class": "system" } ], "links": [ { "source": 1, "target": 0, "value": 1, "type": "depends" }, { "source": 2, "target": 1, "value": 8, "type": "depends" } ]}';
  var data3 = '{ "nodes": [ { "name": "firmware", "group": 1, "class": "system" }, { "name": "loader", "group": 1, "class": "system" }, { "name": "kernel", "group": 1, "class": "system" }, { "name": "systemd", "group": 1, "class": "mount" }, { "name": "mount", "group": 2, "class": "mount" }, { "name": "init.scope", "group": 1, "class": "init" }, { "name": "system.slice", "group": 1, "class": "init" }, { "name": "system-getty.slice", "group": 1, "class": "init" }, { "name": "systemd-initctl.socker", "group": 1, "class": "init" }, { "name": "tmp.mount", "group": 1, "class": "init" }, { "name": "sys-devices", "group": 2, "class": "init" }, { "name": "boot.mount", "group": 2, "class": "mount" }, { "name": "boot.mount.2", "group": 2, "class": "mount" }, { "name": "boot.mount.3", "group": 2, "class": "mount" }, { "name": "boot.mount.4", "group": 2, "class": "mount" }, { "name": "boot.mount.5", "group": 2, "class": "mount" } ], "links": [ { "source": 1, "target": 0, "value": 1, "type": "depends" }, { "source": 2, "target": 1, "value": 8, "type": "depends" }, { "source": 3, "target": 2, "value": 6, "type": "depends" }, { "source": 4, "target": 3, "value": 1, "type": "needs" }, { "source": 4, "target": 2, "value": 5, "type": "needs" }, { "source": 5, "target": 3, "value": 1, "type": "needs" }, { "source": 6, "target": 3, "value": 1, "type": "needs" }, { "source": 7, "target": 3, "value": 1, "type": "needs" }, { "source": 8, "target": 3, "value": 2, "type": "needs" }, { "source": 9, "target": 3, "value": 1, "type": "needs" }, { "source": 11, "target": 10, "value": 1, "type": "depends" }, { "source": 12, "target": 3, "value": 3, "type": "depends" }, { "source": 13, "target": 2, "value": 3, "type": "depends" }, { "source": 14, "target": 2, "value": 5, "type": "needs" }, { "source": 15, "target": 2, "value": 5, "type": "needs" } ]}';

  Vue.config.devtools = true
  Vue.component('dependency-graph', {
    template:
    `<div :style="{ width: width + 'px', height: height + 'px', border: '1px solid black' }">
      <svg width="100%" height="100%">
        <defs>
          <pattern id="innerGrid" :width="innerGridSize" :height="innerGridSize" patternUnits="userSpaceOnUse">
            <rect width="100%" height="100%" fill="none" stroke="#CCCCCC7A" stroke-width="0.5"/>
          </pattern>
          <pattern id="grid" :width="gridSize" :height="gridSize" patternUnits="userSpaceOnUse">
            <rect width="100%" height="100%" fill="url(#innerGrid)" stroke="#CCCCCC7A" stroke-width="1.5"/>
          </pattern>
        </defs>
      </svg>
    </div>`,
    props: ['data'],
    data() {
      return {
        width: 1024,
        height: 768,
        gridSize: 100,
        selections: {},
        simulation: null,
        forceProperties: {
          center: {
            x: 0.5,
            y: 0.5
          },
          charge: {
            enabled: true,
            strength: -700,
            distanceMin: 1,
            distanceMax: 2000
          },
          collide: {
            enabled: true,
            strength: .7,
            iterations: 1,
            radius: 35
          },
          forceX: {
            enabled: true,
            strength: 0.05,
            x: 0.5
          },
          forceY: {
            enabled: true,
            strength: 0.35,
            y: 0.5
          },
          link: {
            enabled: true,
            distance: 100,
            iterations: 1
          }
        },
      }
    },
    computed: {
      innerGridSize() { return this.gridSize / 10 },
      nodes() { return this.data.nodes },
      links() { return this.data.links },
      // These are needed for captions
      linkTypes() {
        const linkTypes = []
        this.links.forEach(link => {
          if (linkTypes.indexOf(link.type) === -1)
            linkTypes.push(link.type)
        })
        return linkTypes.sort()
      },
      classes() {
        const classes = []
        this.nodes.forEach(node => {
          if (classes.indexOf(node.class) === -1)
            classes.push(node.class)
        })
        return classes.sort()
      },
    },
    created() {
      // You can set the component width and height in any way
      // you prefer. It's responsive! :)
      this.width = window.innerWidth - 10
      this.height = window.innerHeight - 110

      this.simulation = d3.forceSimulation()
        .force("link", d3.forceLink())
        .force("charge", d3.forceManyBody())
        .force("collide", d3.forceCollide())
        .force("center", d3.forceCenter())
        .force("forceX", d3.forceX())
        .force("forceY", d3.forceY())
        .on("tick", this.tick)
      // Call first time to setup default values
      this.updateForces()
    },
    mounted() {
      this.selections.svg = d3.select(this.$el.querySelector("svg"))
      const svg = this.selections.svg

      // Add zoom and panning triggers
      this.zoom = d3.zoom()
        .scaleExtent([1 / 4, 4])
        .on('zoom', this.zoomed)
      svg.call(this.zoom)

      // A background grid to help user experience
      // The width and height depends on the minimum scale extent and
      // the + 10% and negative index to create an infinite grid feel
      // The precedence of this element is important since you'll have
      // click events on the elements above the grid
      this.selections.grid = svg.append('rect')
        .attr('x', '-10%')
        .attr('y', '-10%')
        .attr('width', '410%')
        .attr('height', '410%')
        .attr('fill', 'url(#grid)')

      this.selections.graph = svg.append("g")
      const graph = this.selections.graph

      // Node and link count is nice :)
      this.selections.stats = svg.append('text')
        .attr('x', '1%')
        .attr('y', '98%')
        .attr('text-anchor', 'left');

      // Some caption
      this.selections.caption = svg.append('g');
      this.selections.caption.append('rect')
        .attr('width', '200')
        .attr('height', '0')
        .attr('rx', '10')
        .attr('ry', '10')
        .attr('class', 'caption');
    },
    methods: {
      tick() {
        // If no data is passed to the Vue component, do nothing
        if (!this.data) { return }
        const transform = d => {
          return "translate(" + d.x + "," + d.y + ")"
        }

        const link = d => {
          return "M" + d.source.x + "," + d.source.y + " L" + d.target.x + "," + d.target.y
        }

        const graph = this.selections.graph
        graph.selectAll("path").attr("d", link)
        graph.selectAll("circle").attr("transform", transform)
        graph.selectAll("text").attr("transform", transform)

        this.updateNodeLinkCount()
      },
      updateData() {
        this.simulation.nodes(this.nodes)
        this.simulation.force("link").links(this.links)

        const simulation = this.simulation
        const graph = this.selections.graph

        // Links should only exit if not needed anymore
        graph.selectAll("path")
          .data(this.links)
        .exit().remove()

        graph.selectAll("path")
          .data(this.links)
        .enter().append("path")
          .attr("class", d => "link " + d.type)

        // Nodes should always be redrawn to avoid lines above them
        graph.selectAll("circle").remove()
        graph.selectAll("circle")
          .data(this.nodes)
        .enter().append("circle")
          .attr("r", 30)
          .attr("class", d => d.class)
          .call(d3.drag()
            .on('start', this.nodeDragStarted)
            .on('drag', this.nodeDragged)
            .on('end', this.nodeDragEnded))
          .on('mouseover', this.nodeMouseOver)
          .on('mouseout', this.nodeMouseOut)
          .on('click', this.nodeClick)

        graph.selectAll("text").remove()
        graph.selectAll("text")
          .data(this.nodes)
        .enter().append("text")
          .attr("x", 0)
          .attr("y", ".31em")
          .attr("text-anchor", "middle")
          .text(d => d.name)

        // Update caption every time data changes
        this.updateCaption()
        simulation.alpha(1).restart()
      },
      updateForces() {
        const { simulation, forceProperties, width, height } = this
        simulation.force("center")
        .x(width * forceProperties.center.x)
        .y(height * forceProperties.center.y)
        simulation.force("charge")
          .strength(forceProperties.charge.strength * forceProperties.charge.enabled)
          .distanceMin(forceProperties.charge.distanceMin)
          .distanceMax(forceProperties.charge.distanceMax)
        simulation.force("collide")
          .strength(forceProperties.collide.strength * forceProperties.collide.enabled)
          .radius(forceProperties.collide.radius)
          .iterations(forceProperties.collide.iterations)
        simulation.force("forceX")
          .strength(forceProperties.forceX.strength * forceProperties.forceX.enabled)
          .x(width * forceProperties.forceX.x)
        simulation.force("forceY")
          .strength(forceProperties.forceY.strength * forceProperties.forceY.enabled)
          .y(height * forceProperties.forceY.y)
        simulation.force("link")
          .distance(forceProperties.link.distance)
          .iterations(forceProperties.link.iterations)

        // updates ignored until this is run
        // restarts the simulation (important if simulation has already slowed down)
        simulation.alpha(1).restart()
      },
      updateNodeLinkCount() {
        let nodeCount = this.nodes.length;
        let linkCount = this.links.length;

        const highlightedNodes = this.selections.graph.selectAll("circle.highlight");
        const highlightedLinks = this.selections.graph.selectAll("path.highlight");
        if (highlightedNodes.size() > 0 || highlightedLinks.size() > 0) {
          nodeCount = highlightedNodes.size()
          linkCount = highlightedLinks.size()
        }
        this.selections.stats.text('Nodes: ' + nodeCount + ' / Edges: ' + linkCount);
      },
      updateCaption() {
        // WARNING: Some gross math will happen here!
        const lineHeight = 30
        const lineMiddle = (lineHeight / 2)
        const captionXPadding = 28
        const captionYPadding = 5

        const caption = this.selections.caption;
        caption.select('rect')
          .attr('height', (captionYPadding * 2) + lineHeight *
            (this.classes.length + this.linkTypes.length))

        const linkLine = (d) => {
          const source = {
            x: captionXPadding + 13,
            y: captionYPadding + (lineMiddle + 1) + (lineHeight * this.linkTypes.indexOf(d)),
          }
          const target = {
            x: captionXPadding - 10,
          }
          return 'M' + source.x + ',' + source.y + 'H' + target.x
        }

        caption.selectAll('g').remove();
        const linkCaption = caption.append('g');
        linkCaption.selectAll('path')
          .data(this.linkTypes)
          .enter().append('path')
            .attr('d', linkLine)
            .attr('class', (d) => 'link ' + d)

        linkCaption.selectAll('text')
          .data(this.linkTypes)
          .enter().append('text')
            .attr('x', captionXPadding + 20)
            .attr('y', (d) => captionYPadding + (lineMiddle + 5) +
              (lineHeight * this.linkTypes.indexOf(d)))
            .attr('class', 'caption')
            .text((d) => d);

        const classCaption = caption.append('g');
        classCaption.selectAll('circle')
          .data(this.classes)
          .enter().append('circle')
            .attr('r', 10)
            .attr('cx', captionXPadding - 2)
            .attr('cy', (d) => captionYPadding + lineMiddle +
              (lineHeight * (this.linkTypes.length + this.classes.indexOf(d))))
            .attr('class', (d) => d.toLowerCase());

        classCaption.selectAll('text')
          .data(this.classes)
          .enter().append('text')
            .attr('x', captionXPadding + 20)
            .attr('y', (d) => captionYPadding + (lineMiddle + 5) +
              (lineHeight * (this.linkTypes.length + this.classes.indexOf(d))))
            .attr('class', 'caption')
            .text((d) => d);

        const captionWidth = caption.node().getBBox().width;
        const captionHeight = caption.node().getBBox().height;
        const paddingX = 18;
        const paddingY = 12;
        caption
          .attr('transform', 'translate(' +
            (this.width - captionWidth - paddingX) + ', ' +
            (this.height - captionHeight - paddingY) + ')');
      },
      zoomed() {
        const transform = d3.event.transform
        // The trick here is to move the grid in a way that the user doesn't perceive
        // that the axis aren't really moving
        // The actual movement is between 0 and gridSize only for x and y
        const translate = transform.x % (this.gridSize * transform.k) + ',' +
          transform.y % (this.gridSize * transform.k)
        this.selections.grid.attr('transform', 'translate(' +
          translate + ') scale(' + transform.k + ')')
        this.selections.graph.attr('transform', transform)

        // Define some world boundaries based on the graph total size
        // so we don't scroll indefinitely
        const graphBox = this.selections.graph.node().getBBox()
        const margin = 200
        const worldTopLeft = [graphBox.x - margin, graphBox.y - margin]
        const worldBottomRight = [
          graphBox.x + graphBox.width + margin,
          graphBox.y + graphBox.height + margin
        ]
        this.zoom.translateExtent([worldTopLeft, worldBottomRight])
      },
      nodeDragStarted(d) {
        if (!d3.event.active) { this.simulation.alphaTarget(0.3).restart() }
        d.fx = d.x
        d.fy = d.y
      },
      nodeDragged(d) {
        d.fx = d3.event.x
        d.fy = d3.event.y
      },
      nodeDragEnded(d) {
        if (!d3.event.active) { this.simulation.alphaTarget(0.0001) }
        d.fx = null
        d.fy = null
      },
      nodeMouseOver(d) {
        const graph = this.selections.graph
        const circle = graph.selectAll("circle")
        const path = graph.selectAll("path")
        const text = graph.selectAll("text")

        const related = []
        const relatedLinks = []
        related.push(d)
        this.simulation.force('link').links().forEach((link) => {
          if (link.source === d || link.target === d) {
            relatedLinks.push(link)
            if (related.indexOf(link.source) === -1) { related.push(link.source) }
            if (related.indexOf(link.target) === -1) { related.push(link.target) }
          }
        })
        circle.classed('faded', true)
        circle
          .filter((df) => related.indexOf(df) > -1)
          .classed('highlight', true)
        path.classed('faded', true)
        path
          .filter((df) => df.source === d || df.target === d)
          .classed('highlight', true)
        text.classed('faded', true)
        text
          .filter((df) => related.indexOf(df) > -1)
          .classed('highlight', true)
        // This ensures that tick is called so the node count is updated
        this.simulation.alphaTarget(0.0001).restart()
      },
      nodeMouseOut(d) {
        const graph = this.selections.graph
        const circle = graph.selectAll("circle")
        const path = graph.selectAll("path")
        const text = graph.selectAll("text")

        circle.classed('faded', false)
        circle.classed('highlight', false)
        path.classed('faded', false)
        path.classed('highlight', false)
        text.classed('faded', false)
        text.classed('highlight', false)
        // This ensures that tick is called so the node count is updated
        this.simulation.restart()
      },
      nodeClick(d) {
        const circle = this.selections.graph.selectAll("circle")
        circle.classed('selected', false)
        circle.filter((td) => td === d)
          .classed('selected', true)
      },
    },
    watch: {
      data: {
        handler(newData) {
          this.updateData()
        },
        deep: true
      },
      forceProperties: {
        handler(newForce) {
          this.updateForces()
        },
        deep: true
      }
    }
  })

  new Vue({
    el: '#app',
    data() {
      return {
        data: null,
        dataList: [data1, data2, data3],
      }
    },
    created() {
      this.changeData();
    },
    methods: {
      changeData() {
        const dataIndex = Math.floor(Math.random() * this.dataList.length)
        parsed_json = JSON.parse(this.dataList[dataIndex]);
        this.data = parsed_json;
      }
    }
  })
  </script>
  <style>
  .faded {
    opacity: 0.1;
    transition: 0.3s opacity;
  }
  .highlight {
    opacity: 1;
  }

  path.link {
    fill: none;
    stroke: #666;
    stroke-width: 1.5px;
  }
  path.link.depends {
    stroke: #005900;
    stroke-dasharray: 5, 2;
  }
  path.link.needs {
    stroke: #7f3f00;
  }

  circle {
    fill: #ffff99;
    stroke: #191900;
    stroke-width: 1.5px;
  }
  circle.system {
    fill: #cce5ff;
    stroke: #003366;
  }
  circle.mount {
    fill: #ffe5e5;
    stroke: #660000;
  }
  circle.init {
    fill: #b2e8b2;
    stroke: #001900;
  }

  circle.selected {
    stroke: #ff6666FF !important;
    stroke-width: 3px;
    animation: selected 2s infinite alternate ease-in-out;
  }

  @keyframes selected {
    from {
      stroke-width: 5px;
      r: 26;
    }
    to {
      stroke-width: 1px;
      r: 30;
    }
  }

  text {
    font: 10px sans-serif;
    pointer-events: none;
    text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
  }

  rect.caption {
    fill: #CCCCCCAC;
    stroke: #666;
    stroke-width: 1px;
  }
  text.caption {
    font-size: 14px;
    font-weight: bold;
  }
  </style>
</body>

注释#1 :JSON文件现在以字符串形式嵌入:

var data1 = '{ "nodes": [ ..., "type": "needs" } ]}';
var data2 = '{ "nodes": [ ..., "value": 8, "type": "depends" } ]}';
var data3 = '{ "nodes": [ ..., "value": 5, "type": "needs" } ]}';

注释#2 :该脚本现在引用这些变量,而不是基于Web的文件,并且它使用JSON.parse来解析字符串,而不是使用基于XHR的{{ 1}}:

d3.json

因此,我们只有一个文件new Vue({ el: '#app', data() { return { data: null, dataList: [data1, data2, data3], } }, created() { this.changeData(); }, methods: { changeData() { const dataIndex = Math.floor(Math.random() * this.dataList.length) parsed_json = JSON.parse(this.dataList[dataIndex]); this.data = parsed_json; } } }) 可以运行,并且其中已嵌入JSON文件。

那是什么问题?

当我双击修改后的index.html时,窗口将显示除渲染图形以外的所有内容。要显示该图,我需要单击“更改数据”按钮。

这与原始版本不同,在原始版本中,无需单击按钮即可立即显示初始图形。

index.html到手动解析嵌入的JSON字符串似乎在某种程度上扰乱了Vue的反应性响应。

尝试解决

我尝试在首次创建Vue对象(“ dependency-graph”)时强制Vue渲染图失败

  • 使用d3.json模拟按钮的点击
  • Numerous methods强制Vue渲染当前数据

不良的“黑客入侵”解决方案

通过将全局变量设置为引用document.getElementById("app").click()函数,然后在脚本部分底部的全局变量中进行调用,我能够使它在初始创建后呈现图形。

this.changeData

不用说,必须有一种更优雅,更正确的方式。

问题

  • 当我从基于Web的JSON文件的本地访问切换到本地访问时,为什么Vue反应式引擎搞砸了?
  • 在创建页面时如何使图形自动呈现,而不是要求我按下“更改数据”按钮?

0 个答案:

没有答案