d3 sincronizando 2 comportamentos de zoom separados

11

Eu tenho o seguinte gráfico d3 / d3fc

https://codepen.io/parliament718/pen/BaNQPXx

O gráfico possui um comportamento de zoom para a área principal e um comportamento de zoom separado para o eixo y. O eixo y pode ser arrastado para redimensionar.

O problema que estou tendo para solucionar problemas é que, depois de arrastar o eixo y para redimensionar a escala e, posteriormente, movimentar o gráfico, há um "salto" no gráfico.

Obviamente, os 2 comportamentos de zoom têm uma desconexão e precisam ser sincronizados, mas estou destruindo meu cérebro tentando consertar isso.

const mainZoom = zoom()
    .on('zoom', () => {
       xScale.domain(t.rescaleX(x2).domain());
       yScale.domain(t.rescaleY(y2).domain());
    });

const yAxisZoom = zoom()
    .on('zoom', () => {
        const t = event.transform;
        yScale.domain(t.rescaleY(y2).domain());
        render();
    });

const yAxisDrag = drag()
    .on('drag', (args) => {
        const factor = Math.pow(2, -event.dy * 0.01);
        plotArea.call(yAxisZoom.scaleBy, factor);
    });

O comportamento desejado é aplicar zoom, panorâmica e / ou redimensionar o eixo para aplicar sempre a transformação de onde quer que a ação anterior seja concluída, sem nenhum "salto".

parlamento
fonte

Respostas:

10

OK, então já tive outra chance - como mencionado na minha resposta anterior , o maior problema que você precisa superar é que o zoom d3 permite apenas dimensionamento simétrico. Isso é algo amplamente discutido , e acredito que Mike Bostock abordará isso no próximo lançamento.

Portanto, para superar o problema, você precisa usar o comportamento de zoom múltiplo. Criei um gráfico com três, um para cada eixo e outro para a área de plotagem. Os comportamentos de zoom X e Y são usados ​​para dimensionar os eixos. Sempre que um evento de zoom é gerado pelos comportamentos de zoom X e Y, seus valores de conversão são copiados para a área de plotagem. Da mesma forma, quando ocorre uma translação na área de plotagem, os componentes x e y são copiados para os respectivos comportamentos do eixo.

A escala na área de plotagem é um pouco mais complicada, pois precisamos manter a proporção. Para conseguir isso, armazeno a transformação de zoom anterior e uso o delta da escala para elaborar uma escala adequada para aplicar aos comportamentos de zoom X e Y.

Por conveniência, agrupei tudo isso em um componente do gráfico:


const interactiveChart = (xScale, yScale) => {
  const zoom = d3.zoom();
  const xZoom = d3.zoom();
  const yZoom = d3.zoom();

  const chart = fc.chartCartesian(xScale, yScale).decorate(sel => {
    const plotAreaNode = sel.select(".plot-area").node();
    const xAxisNode = sel.select(".x-axis").node();
    const yAxisNode = sel.select(".y-axis").node();

    const applyTransform = () => {
      // apply the zoom transform from the x-scale
      xScale.domain(
        d3
          .zoomTransform(xAxisNode)
          .rescaleX(xScaleOriginal)
          .domain()
      );
      // apply the zoom transform from the y-scale
      yScale.domain(
        d3
          .zoomTransform(yAxisNode)
          .rescaleY(yScaleOriginal)
          .domain()
      );
      sel.node().requestRedraw();
    };

    zoom.on("zoom", () => {
      // compute how much the user has zoomed since the last event
      const factor = (plotAreaNode.__zoom.k - plotAreaNode.__zoomOld.k) / plotAreaNode.__zoomOld.k;
      plotAreaNode.__zoomOld = plotAreaNode.__zoom;

      // apply scale to the x & y axis, maintaining their aspect ratio
      xAxisNode.__zoom.k = xAxisNode.__zoom.k * (1 + factor);
      yAxisNode.__zoom.k = yAxisNode.__zoom.k * (1 + factor);

      // apply transform
      xAxisNode.__zoom.x = d3.zoomTransform(plotAreaNode).x;
      yAxisNode.__zoom.y = d3.zoomTransform(plotAreaNode).y;

      applyTransform();
    });

    xZoom.on("zoom", () => {
      plotAreaNode.__zoom.x = d3.zoomTransform(xAxisNode).x;
      applyTransform();
    });

    yZoom.on("zoom", () => {
      plotAreaNode.__zoom.y = d3.zoomTransform(yAxisNode).y;
      applyTransform();
    });

    sel
      .enter()
      .select(".plot-area")
      .on("measure.range", () => {
        xScaleOriginal.range([0, d3.event.detail.width]);
        yScaleOriginal.range([d3.event.detail.height, 0]);
      })
      .call(zoom);

    plotAreaNode.__zoomOld = plotAreaNode.__zoom;

    // cannot use enter selection as this pulls data through
    sel.selectAll(".y-axis").call(yZoom);
    sel.selectAll(".x-axis").call(xZoom);

    decorate(sel);
  });

  let xScaleOriginal = xScale.copy(),
    yScaleOriginal = yScale.copy();

  let decorate = () => {};

  const instance = selection => chart(selection);

  // property setters not show 

  return instance;
};

Aqui está uma caneta com o exemplo de trabalho:

https://codepen.io/colineberhardt-the-bashful/pen/qBOEEGJ

ColinE
fonte
Colin, muito obrigado por dar a este outro passo. Abri o seu codepen e notei que ele não funciona conforme o esperado. No meu codepen, arrastar o eixo Y redimensiona o gráfico (esse é o comportamento desejado). No seu codepen, arrastar o eixo simplesmente move o gráfico.
parliament
11
Eu consegui criar um que permite que você arraste em ambos os y-escala e da área de plotagem codepen.io/colineberhardt-the-bashful/pen/mdeJyrK - mas ampliando a área de plotagem é um grande desafio
Coline
5

Existem alguns problemas no seu código, um que é fácil de resolver e outro que não é ...

Em primeiro lugar, o d3-zoom funciona armazenando uma transformação nos elementos DOM selecionados - você pode ver isso através da __zoompropriedade Quando o usuário interage com o elemento DOM, essa transformação é atualizada e os eventos são emitidos. Portanto, se você tiver comportamentos diferentes de zoom, ambos controlando a panorâmica / zoom de um único elemento, precisará manter essas transformações sincronizadas.

Você pode copiar a transformação da seguinte maneira:

selection.call(zoom.transform, d3.event.transform);

No entanto, isso também fará com que os eventos de zoom sejam disparados a partir do comportamento de destino.

Uma alternativa é copiar diretamente para a propriedade de transformação 'stashed':

selection.node().__zoom = d3.event.transform;

No entanto, há um problema maior com o que você está tentando alcançar. A transformação d3-zoom é armazenada como 3 componentes de uma matriz de transformação:

https://github.com/d3/d3-zoom#zoomTransform

Como resultado, o zoom pode representar apenas uma escala simétrica junto com uma tradução. Seu zoom assimétrico aplicado ao eixo x não pode ser fielmente representado por essa transformação e reaplicado à área da plotagem.

ColinE
fonte
Obrigado Colin, desejo que tudo isso faça sentido para mim, mas não entendo qual é a solução. Acrescentarei uma recompensa de 500 a esta pergunta assim que puder e, espero, alguém possa ajudar a consertar meu codepen.
parlamento
Não se preocupe, não é um problema fácil de resolver, mas uma recompensa pode atrair algumas ofertas de ajuda.
ColinE 07/04
2

Este é um próximo recurso , como já observado pelo @ColinE. O código original está sempre fazendo um "zoom temporal" que não é sincronizado a partir da matriz de transformação.

A melhor solução é ajustar o xExtentintervalo para que o gráfico acredite que há velas adicionais nas laterais. Isso pode ser conseguido adicionando almofadas nas laterais. O accessors, em vez de ser,

[d => d.date]

torna-se,

[
  () => new Date(taken[0].date.addDays(-xZoom)), // Left pad
  d => d.date,
  () => new Date(taken[taken.length - 1].date.addDays(xZoom)) // Right pad
]

Nota: Note que existe uma padfunção que deve fazer isso, mas, por algum motivo, ela funciona apenas uma vez e nunca é atualizada novamente, por isso é adicionada como uma accessors.

Nota 2: Função addDaysadicionada como um protótipo (não é a melhor coisa a fazer) apenas por simplicidade.

Agora, o modifica evento zoom nosso fator de zoom X, xZoom,

zoomFactor = Math.sign(d3.event.sourceEvent.wheelDelta) * -5;
if (zoomFactor) xZoom += zoomFactor;

É importante ler o diferencial diretamente dewheelDelta . É aqui que está o recurso não suportado: Não podemos ler, t.xpois ele mudará mesmo se você arrastar o eixo Y.

Por fim, recalcule chart.xDomain(xExtent(data.series));para que a nova extensão esteja disponível.

Veja a demonstração de trabalho sem pular aqui: https://codepen.io/adelriosantiago/pen/QWjwRXa?editors=0011

Corrigido: Reversão de zoom, comportamento aprimorado no trackpad.

Tecnicamente, você também pode ajustar yExtentadicionando extras d.highe d.low's. Ou até os dois xExtente yExtentpara evitar o uso da matriz de transformação.

adelriosantiago
fonte
Obrigado. Infelizmente, o comportamento do zoom aqui está realmente confuso. O zoom parece muito irregular e, em alguns casos, inverte a direção várias vezes (usando o macbook trackpad). O comportamento do zoom precisa permanecer o mesmo da caneta original.
parliament
Atualizei a caneta com algumas melhorias, principalmente o zoom reverso.
adelriosantiago 17/04
11
Vou premiar você pelo seu esforço e estou começando um segundo, porque ainda preciso de uma resposta com melhor zoom / panorâmica. Infelizmente, esse método com base em alterações delta ainda é instável e não é suave ao aplicar zoom e ao fazer o movimento panorâmico muito rápido. Eu suspeito que você provavelmente pode ajustar o fator indefinidamente e ainda assim não conseguir um comportamento tranquilo. Estou procurando uma resposta que não mude o comportamento de zoom / panorâmica da caneta original.
parliament
11
Também ajustei a caneta original para ampliar apenas ao longo do eixo X. Esse é o comportamento merecido e agora percebo que isso pode complicar a resposta.
parliament