How to make attribute of parent element depend on calculations from generation of child elements?

I’m trying to build a knockoff implementation of Catan as a learning exercise.

My full codebase is here (it’s nowhere near functional yet, though it does render the demo board).

In it, I’ve got this HEEx template:

<svg xmlns="http://www.w3.org/2000/svg" class="board" style="/* overflow: visible; */ border: 2pt dotted red;" width="500" height="500">

    <g class="layer_terrain">
        <% terrain = @game.state.board.terrain; %>
        <% jMax = Arrays.size(terrain) - 1; %>
        <%= for {row, j} <- jMax..0 |> Stream.map(fn i -> {terrain[i], i} end) do %>
            <% j_ = jMax - j; %>
            <%= for {tile, i} <- Stream.with_index(row) |> Stream.filter(fn {x, _} -> not is_nil(x) end) do %>
                <% coords = SettlersView.Util.calc_coords(:"point-top", :tile, i, j, j_); %>
                <% classlist_loc = ["row-#{i}", "col-#{j}"]; %>

                <% background_color = SettlersView.Util.lookup_tile_color(tile.type) %>

                <circle cx={coords.x0} cy={coords.y0} r="48" fill={background_color} class={["terrain-background" | classlist_loc]} />
                <%= if not is_nil(tile.yield) do %>
                    <circle cx={coords.x0} cy={coords.y0} r="16" fill={SettlersView.Util.lookup_tile_color(:_cardboard)} class={["terrain-yield-circle" | classlist_loc]} />
                    <text x={coords.x0} y={coords.y0} dominant-baseline="middle" text-anchor="middle" class={["terrain-yield-label" | classlist_loc]}><%= tile.yield %></text>
                <% end %>

            <% end %>
        <% end %>
    </g>

    <g class="layer_structure">
        <% %{tiles: tiles, edges: edges, intersections: intersections} = @game.state.board.structures; %>

        <% jMax = Arrays.size(tiles) - 1; %>
        <%= for {row, j} <- jMax..0 |> Stream.map(fn i -> {tiles[i], i} end) do %>
            <% j_ = jMax - j; %>
            <%= for {structures, i} <- Stream.with_index(row) do %>
                <% coords = SettlersView.Util.calc_coords(:"point-top", :tile, i, j, j_); %>
                <% classlist_loc = ["row-#{i}", "col-#{j}"]; %>
                <%= for structure <- structures do %>

                    <%= case structure.type do %>
                        <% :robber -> %>
                            <text font-size="32" x={coords.x0 - 24} y={coords.y0 + 16} text-anchor="middle" >&#x265f;&#xfe0e;</text>
                        <% :merchant -> %>
                            <text font-size="32" x={coords.x0 + 24} y={coords.y0 + 16} text-anchor="middle" fill="purple" >&#x265d;&#xfe0e;</text>
                        <% _ -> %>
                            <text x={coords.x0 - 16} y={coords.y0} transform="rotate(-90)" transform-origin={"#{coords.x0 - 16} #{coords.y0}"} dominant-baseline="middle" text-anchor="middle" fill="magenta" class={["structure-debug" | classlist_loc]}><%= structure.type %></text>
                    <% end %>

                <% end %>
            <% end %>
        <% end %>

        <% jMax = Arrays.size(edges) - 1; %>
        <%= for {row, j} <- jMax..0 |> Stream.map(fn i -> {edges[i], i} end) do %>
            <% j_ = jMax - j; %>
            <%= for {positions, i} <- Stream.with_index(row) do %>
                <%= for {structures, k} <- Stream.with_index(positions) do %>
                    <% coords = SettlersView.Util.calc_coords(:"point-top", :edge, i, j, j_, k); %>
                    <% classlist_loc = ["row-#{i}", "col-#{j}", "pos-#{k}"]; %>
                    <%= for structure <- structures do %>

                        <%= case structure.type do %>
                            <% :road -> %>
                                <% owner_color = @game.player_cosmetic[structure.owner].color; %>
                                <rect width="32" height="6" transform={"rotate(#{coords.rot})"} transform-origin={"#{coords.x0} #{coords.y0}"} x={coords.x0 - 32/2} y={coords.y0 - 6/2} dominant-baseline="middle" text-anchor="middle" fill={owner_color} class={["structure-road" | classlist_loc]} />
                            <% _ -> %>
                                <text x={coords.x0} y={coords.y0} transform={"rotate(#{coords.rot})"} transform-origin={"#{coords.x0} #{coords.y0}"} dominant-baseline="middle" text-anchor="middle" fill="magenta" class={["structure-debug" | classlist_loc]}><%= structure.type %></text>
                        <% end %>

                    <% end %>
                <% end %>
            <% end %>
        <% end %>

        <% jMax = Arrays.size(intersections) - 1; %>
        <%= for {row, j} <- jMax..0 |> Stream.map(fn i -> {intersections[i], i} end) do %>
            <% j_ = jMax - j; %>
            <%= for {positions, i} <- Stream.with_index(row) do %>
                <%= for {structures, k} <- Stream.with_index(positions) do %>
                    <% coords = SettlersView.Util.calc_coords(:"point-top", :intersection, i, j, j_, k); %>
                    <% classlist_loc = ["row-#{i}", "col-#{j}", "pos-#{k}"]; %>
                    <%= for structure <- structures do %>

                        <%= case structure.type do %>
                            <% :settlement -> %>
                                <g transform={"translate(#{coords.x0} #{coords.y0})"} class={["structure-settlement" | classlist_loc]}>
                                    <polygon fill={@game.player_cosmetic[structure.owner].color} points="0,-12.5 10,-2.5 10,10 -10,10 -10,-2.5" />
                                </g>
                            <% _ -> %>
                                <text x={coords.x0} y={coords.y0} dominant-baseline="middle" text-anchor="middle" fill="magenta" class={["structure-debug" | classlist_loc]}><%= structure.type %></text>
                        <% end %>

                    <% end %>
                <% end %>
            <% end %>
        <% end %>
    </g>

</svg>

I would like to make the viewBox, width, and height attributes of the master SVG element a function of all the various coords that are calculated during board creation, but I can’t see how to pull this off. Is this possible, or would trying to make a parent element depend on its child elements in this way create a “logical circle” that’s illegal for functional programming?

1 Like

I’d suggest pulling out all the logic calculating these coordinates from the template. You’d want to give the template a set of data, which the template for the most part renders as is. That will also mean you’ll have the actual coordinates before passing the data off for rendering, hence you will be able to calculate the boundary and pass that to the template as well for being used.

I’d argue that whenever you do <% … %> (no =) you’re likely doing something in the template, which should be done elsewhere. Either in a function component or even before starting to render the template, where the former is really a smaller scale version of the latter.

3 Likes

Also important to note that assigning variables inside <% ... %> breaks change-tracking.

3 Likes

Ah, I had moved my variable assignments from a separate function, to inline in the .heex file, because I thought it would un-break change tracking (I thought this because it suppressed the warning in my console). Thanks for clarifying this is “still broken”.

I eventually realized that you’re supposed to use this pseudo-tag syntax to call your functions as if they were elements. Does this look like it would be compatible with change tracking (no variables bound except those by Elixir language constructs like for), or is this mass of arithmetic operations still going to break it?:

<svg xmlns="http://www.w3.org/2000/svg" class="board" style="/* overflow: visible; */ border: 2pt dotted red;" width="480" height="415">

    <g class="layer_terrain">
        <%!-- iterate rows in reverse order for rendering --%>
        <%= for {row, j} <- Enum.reverse(Enum.with_index(@game.state.board.terrain)) do %>
            <%= for {tile, i} <- Enum.with_index(row) do %>
                <SettlersView.tile
                    tile={tile}
                    i={i}
                    j={j}
                    coords={SettlersView.Util.calc_coords(
                        :tile,
                        :"point-top",
                        i,
                        j,
                        1,
                        Arrays.size(@game.state.board.structures.tiles) - 2
                    )}
                />
            <% end %>
        <% end %>
    </g>
</svg>
defmodule SettlersView do
  use Phoenix.Component
  def tile(assigns) do
    ~H"""
    <%= if not is_nil(@tile) do %>
      <circle
        cx={@coords.x0}
        cy={@coords.y0}
        r="48"
        class={["row-#{@j}", "col-#{@i}", "terrain-background"]}
        fill={SettlersView.Util.lookup_tile_color(@tile.type)}
      />
      <%= if not is_nil(@tile.yield) do %>
        <circle
          cx={@coords.x0}
          cy={@coords.y0}
          r="12"
          class={["row-#{@j}", "col-#{@i}", "terrain-yield-pip"]}
          fill={SettlersView.Util.lookup_tile_color(:_cardboard)}
        />
        <text
          x={@coords.x0}
          y={@coords.y0}
          font-size={24 - abs(7 - @tile.yield) * 3}
          fill={if abs(7 - @tile.yield) < 2 do "red" else "black" end}
          text-anchor="middle"
          dominant-baseline="central"
        >
          <%= @tile.yield %>
        </text>
      <% end %>
    <% end %>
    """
  end
end
1 Like

At a quick glance, it looks like change tracking wouldn’t be broken. But, you will definitely be getting lots of full re-renders with those nested loops. Probably not trivial to avoid given it’s a game though, lol.

A note on if and for: If you don’t need the else branch, and the content of the if or for is a single top-level element, you can use the :if={} and :for={} directive in the element instead of the tag syntax. Definitely helps clean things up on complex templates like this.

2 Likes

Checking the documentation, I can’t find how to use those without creating an unnecessary(?) container tag. Would injecting an extra nested <g> for each row and cell in order to use that alternate syntax allow Phoenix to manage the state more efficiently?

But, you will definitely be getting lots of full re-renders with those nested loops.

In this case:

  • It’s provisionally 100% OK to trigger a full re-render if @game.state.terrain changes (because that will either never happen, or happen a finite amount of times in a given game);

  • It’s definitely 100% OK to trigger a full re-render if @game.player_cosmetic changes;

  • I would like to try for partial updates on changes to sub-objects of @game.state.structures. This object contains around w*w + (w+1)*(h+1) + (w+1)*(h+1) List objects (which is 177 for the default board size), and virtually every update to the structures is going to add, remove, or alter just 1 of those objects (in fact, generally just 1 element of the object).

Is there any way to represent that last fact to Phoenix to try to make the updates snappier? It would be cool to keep solid performance even when players are testing the limits with absurdly large board sizes.

The use of directives is covered toward the bottom of the syntax documentation (look for a header named :if and :for. Keep in mind this is just syntax sugar to make things cleaner for the eye, doesn’t affect performance.

Streams are the go-to for list optimization. If you could separate your loops and manage each of them with a stream, the updates could be very granular. That’s going to depend on how you structure your SVGs, and I’m not familiar enough with them to know if that’s possible. I will leave that puzzle to you. :slight_smile:

If you could separate your loops and manage each of them with a stream, the updates could be very granular. That’s going to depend on how you structure your SVGs, and I’m not familiar enough with them to know if that’s possible.

Well, the SVG structure is currently just a near-mirror of the struct the underlying Model uses for the game state, with the terrain (that never changes and should be rendered behind everything else anyway) broken out into a separate <g> layer:

[
  g: @game.state.terrain, # [height, width]-Array of objects
  g: {
    @game.state.structures.tiles, # [height, width]-Array of Lists of objects
    @game.state.structures.edges, # [height+1, width+1, 3]-Array of Lists of objects
    @game.state.structures.intersections # [height+1, width+1, 2]-Array of Lists of objects
  }
]
@game = %Game{…}
defmodule SettlersModel do
  defmodule Game do
    @enforce_keys [:state]
    defstruct [:state, :player_cosmetic]
  end

  defmodule GameState do
    @enforce_keys [:board, :players]
    defstruct [:board, :players, turn: 0]
  end

  defmodule Catan do
    @enforce_keys [:terrain, :structures]
    # * terrain will be a W-by-H array of TerrainTile objects
    defstruct [:terrain, :structures]
  end

  defmodule TerrainTile do
    @enforce_keys [:type]
    defstruct [type: :ocean, yield: :nil]
  end

  defmodule Catan.Structures do
    @enforce_keys [:tiles, :edges, :intersections]
    # * tiles will be a W-by-H array of Lists of Structure objects
    # * edges will be a (W+1)-by-(H+1)-by-3 array of Lists of Structure objects
    # * intersections will be a (W+1)-by-(H+1)-by-2 array of Lists of Structure objects
    defstruct [:tiles, :edges, :intersections]
  end

  defmodule Structure do
    @enforce_keys [:type]
    defstruct [type: :_undefined, owner: nil, detail: nil]
  end

  defmodule Player do
    defmodule Cosmetic do
      defstruct [:name, :color]
    end
    defstruct [:resource_hand, :development_hand]
  end

  def new_game(state \\ %GameState{
    board: %Catan{
      terrain: Arrays.new([
        Arrays.new([%TerrainTile{type: :ocean}, %TerrainTile{type: :ocean}, %TerrainTile{type: :ocean}, %TerrainTile{type: :ocean}, nil, nil, nil]),
        Arrays.new([%TerrainTile{type: :ocean}, %TerrainTile{type: :hills, yield: 5}, %TerrainTile{type: :fields, yield: 6}, %TerrainTile{type: :pasture, yield: 11}, %TerrainTile{type: :ocean}, nil, nil]),
        Arrays.new([%TerrainTile{type: :ocean}, %TerrainTile{type: :forest, yield: 8}, %TerrainTile{type: :mountains, yield: 3}, %TerrainTile{type: :fields, yield: 4}, %TerrainTile{type: :pasture, yield: 5}, %TerrainTile{type: :ocean}, nil]),
        Arrays.new([%TerrainTile{type: :ocean}, %TerrainTile{type: :fields, yield: 9}, %TerrainTile{type: :forest, yield: 11}, %TerrainTile{type: :desert}, %TerrainTile{type: :forest, yield: 3}, %TerrainTile{type: :mountains, yield: 8}, %TerrainTile{type: :ocean}]),
        Arrays.new([nil, %TerrainTile{type: :ocean}, %TerrainTile{type: :fields, yield: 12}, %TerrainTile{type: :hills, yield: 6}, %TerrainTile{type: :pasture, yield: 4}, %TerrainTile{type: :hills, yield: 10}, %TerrainTile{type: :ocean}]),
        Arrays.new([nil, nil, %TerrainTile{type: :ocean}, %TerrainTile{type: :mountains, yield: 10}, %TerrainTile{type: :pasture, yield: 2}, %TerrainTile{type: :forest, yield: 9}, %TerrainTile{type: :ocean}]),
        Arrays.new([nil, nil, nil, %TerrainTile{type: :ocean}, %TerrainTile{type: :ocean}, %TerrainTile{type: :ocean}, %TerrainTile{type: :ocean}])
      ]),
      structures: %Catan.Structures{
        tiles: Arrays.new([
          Arrays.new([[%Structure{type: :port, detail: %{type: :_any, position: 1}}], [], [%Structure{type: :port, detail: %{type: :_any, position: 2}}], [], [], [], []]),
          Arrays.new([[], [], [], [], [%Structure{type: :port, detail: %{type: :wool, position: 2}}], [], []]),
          Arrays.new([[%Structure{type: :port, detail: %{type: :brick, position: 0}}], [], [], [], [], [], []]),
          Arrays.new([[], [], [], [%Structure{type: :robber}], [], [], [%Structure{type: :port, detail: %{type: :_any, position: 3}}]]),
          Arrays.new([[], [%Structure{type: :port, detail: %{type: :lumber, position: 0}}], [], [], [], [], []]),
          Arrays.new([[], [], [], [], [], [], [%Structure{type: :port, detail: %{type: :ore, position: 4}}]]),
          Arrays.new([[], [], [], [%Structure{type: :port, detail: %{type: :_any, position: 5}}], [], [%Structure{type: :port, detail: %{type: :grain, position: 4}}], []])
        ]),
        edges: Arrays.new([
          Arrays.new([Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []])]),
          Arrays.new([Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []])]),
          Arrays.new([Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [%Structure{type: :road, owner: 0}], []]), Arrays.new([[], [%Structure{type: :road, owner: 3}], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []])]),
          Arrays.new([Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [%Structure{type: :road, owner: 1}], []]), Arrays.new([[], [], []]), Arrays.new([[%Structure{type: :road, owner: 0}], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []])]),
          Arrays.new([Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], [%Structure{type: :road, owner: 2}]]), Arrays.new([[], [], []]), Arrays.new([[%Structure{type: :road, owner: 2}], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []])]),
          Arrays.new([Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [%Structure{type: :road, owner: 1}], []]), Arrays.new([[], [%Structure{type: :road, owner: 3}], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []])]),
          Arrays.new([Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []])]),
          Arrays.new([Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []]), Arrays.new([[], [], []])])
        ]),
        intersections: Arrays.new([
          Arrays.new([Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []])]),
          Arrays.new([Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []])]),
          Arrays.new([Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], [%Structure{type: :settlement, owner: 0}]]), Arrays.new([[], [%Structure{type: :settlement, owner: 3}]]), Arrays.new([[], [%Structure{type: :settlement, owner: 0}]]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []])]),
          Arrays.new([Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], [%Structure{type: :settlement, owner: 1}]]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], [%Structure{type: :settlement, owner: 2}]]), Arrays.new([[], []]), Arrays.new([[], []])]),
          Arrays.new([Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], [%Structure{type: :settlement, owner: 2}]]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []])]),
          Arrays.new([Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], [%Structure{type: :settlement, owner: 1}]]), Arrays.new([[%Structure{type: :settlement, owner: 3}], []]), Arrays.new([[], []]), Arrays.new([[], []])]),
          Arrays.new([Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []])]),
          Arrays.new([Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []]), Arrays.new([[], []])])
        ])
      }
    },
    players: Arrays.new([
      %Player{
        development_hand: %{},
        resource_hand: %{ore: 1, lumber: 1, brick: 1}
      },
      %Player{
        development_hand: %{},
        resource_hand: %{lumber: 2, grain: 1}
      },
      %Player{
        development_hand: %{},
        resource_hand: %{brick: 1, grain: 1, lumber: 1}
      },
      %Player{
        development_hand: %{},
        resource_hand: %{grain: 2, ore: 1}
      }
    ])
  }, player_cosmetic \\ Arrays.new([
    %Player.Cosmetic{name: "Player 1", color: "blue"},
    %Player.Cosmetic{name: "Player 2", color: "red"},
    %Player.Cosmetic{name: "Player 3", color: "white"},
    %Player.Cosmetic{name: "Player 4", color: "orange"},
  ])) do
    %Game{state: state, player_cosmetic: player_cosmetic}
  end
end

however, that struct is open to being tweaked or replaced if its current structure is somehow a fundamental mis-fit with Phoenix.

Thanks for the pointer; I didn’t realize Phoenix has a special datastructure to use for massive arrays. :face_with_monocle: