Current section
Files
Jump to
Current section
Files
lib/yog/io/json.ex
defmodule Yog.IO.JSON do
@moduledoc """
JSON format export for graph data exchange (WRITE-ONLY).
This module provides comprehensive JSON export capabilities for graph data,
supporting multiple formats used by popular visualization libraries.
**Note:** This module currently supports WRITE operations only. Import/read
functionality is not implemented. For bidirectional I/O, consider using
GraphML, GDF, TGF, LEDA, or Pajek formats.
## Format Support
- **Generic**: Full metadata with type preservation
- **D3Force**: D3.js force-directed graphs
- **Cytoscape**: Cytoscape.js network visualization
- **VisJs**: vis.js network format
- **NetworkX**: Python NetworkX compatibility
## Example
iex> graph = Yog.directed()
...> |> Yog.add_node(1, "Alice")
...> |> Yog.add_node(2, "Bob")
...> |> Yog.add_edge!(from: 1, to: 2, with: "follows")
iex>
iex> json_string = Yog.IO.JSON.to_json(graph, Yog.IO.JSON.default_export_options())
iex> String.contains?(json_string, "Alice")
true
iex> String.contains?(json_string, "Bob")
true
"""
@doc """
Creates default export options for String node and edge data.
"""
def default_export_options do
{:json_export_options, :yog_generic, true, &default_node_serializer/1,
&default_edge_serializer/1, false, %{}}
end
@doc """
Creates export options with custom serializers for generic types.
"""
def export_options_with(node_serializer, edge_serializer) do
{:json_export_options, :yog_generic, true, node_serializer, edge_serializer, false, %{}}
end
@doc """
Converts a graph to a JSON string according to options.
"""
def to_json(graph, options) do
{:json_export_options, format, include_metadata?, node_ser, edge_ser, _pretty?, _meta} =
options
case format do
:yog_generic ->
to_generic_format(graph, node_ser, edge_ser, include_metadata?)
:network_x ->
to_networkx_format(graph, node_ser, edge_ser, include_metadata?)
:d3_force ->
to_d3_format(graph, node_ser, edge_ser)
:cytoscape ->
to_cytoscape_format(graph, node_ser, edge_ser)
:visjs ->
to_visjs_format(graph, node_ser, edge_ser)
_ ->
to_generic_format(graph, node_ser, edge_ser, include_metadata?)
end
|> Jason.encode!()
end
@doc """
Exports a graph to a JSON file.
"""
def to_json_file(graph, path, options) do
json_string = to_json(graph, options)
case File.write(path, json_string) do
:ok -> {:ok, nil}
error -> error
end
end
@doc """
Quick export for D3.js force-directed graphs with default settings.
"""
def to_d3_json(graph, node_serializer, edge_serializer) do
options =
{:json_export_options, :d3_force, false, node_serializer, edge_serializer, false, %{}}
to_json(graph, options)
end
@doc """
Quick export for Cytoscape.js with default settings.
"""
def to_cytoscape_json(graph, node_serializer, edge_serializer) do
options =
{:json_export_options, :cytoscape, false, node_serializer, edge_serializer, false, %{}}
to_json(graph, options)
end
@doc """
Quick export for vis.js networks with default settings.
"""
def to_visjs_json(graph, node_serializer, edge_serializer) do
options = {:json_export_options, :visjs, false, node_serializer, edge_serializer, false, %{}}
to_json(graph, options)
end
@doc """
Writes a graph to a JSON file using default export options.
"""
def write(path, graph) do
to_json_file(graph, path, default_export_options())
end
@doc """
Writes a graph to a JSON file with custom export options.
"""
def write_with(path, options, graph) do
to_json_file(graph, path, options)
end
@doc """
Converts a multigraph to a JSON string.
"""
def to_json_multi(graph, options) do
{:json_export_options, _format, include_metadata?, node_ser, edge_ser, _pretty?, _meta} =
options
to_generic_multi_format(graph, node_ser, edge_ser, include_metadata?)
|> Jason.encode!()
end
@doc """
Exports a multigraph to a JSON file.
"""
def to_json_file_multi(graph, path, options) do
json_string = to_json_multi(graph, options)
File.write(path, json_string)
end
@doc """
Converts a JsonError to a string.
"""
def error_to_string(error) do
inspect(error)
end
# Private functions
defp default_node_serializer(data) do
data
end
defp default_edge_serializer(data) do
data
end
# Convert Gleam JSON iolist to Elixir term
defp gleam_json_to_term(iolist) when is_list(iolist) do
iolist
|> IO.iodata_to_binary()
|> Jason.decode!()
end
defp gleam_json_to_term(other), do: other
# Serialize data using the provided serializer
defp serialize_data(data, serializer) do
result = serializer.(data)
# Check if result is a Gleam JSON iolist (starts with numbers or nested lists)
if is_list(result) and (is_integer(List.first(result)) or is_list(List.first(result))) do
gleam_json_to_term(result)
else
result
end
end
defp to_generic_format(graph, node_ser, edge_ser, include_metadata?) do
{:graph, type, nodes_map, _, _} = graph
graph_type = if type == :directed, do: "directed", else: "undirected"
edges = get_all_edges(graph)
nodes_json =
Enum.map(nodes_map, fn {id, data} ->
%{
id: id,
data: serialize_data(data, node_ser)
}
end)
edges_json =
Enum.map(edges, fn {from, to, weight} ->
%{
source: from,
target: to,
weight: serialize_data(weight, edge_ser)
}
end)
result = %{
format: "yog-generic",
version: "2.0",
graph_type: graph_type,
nodes: nodes_json,
edges: edges_json
}
if include_metadata? do
Map.put(result, :metadata, build_metadata(graph))
else
result
end
end
defp to_networkx_format(graph, node_ser, edge_ser, include_metadata?) do
{:graph, type, nodes_map, _, _} = graph
directed = type == :directed
edges = get_all_edges(graph)
nodes_json =
Enum.map(nodes_map, fn {id, data} ->
%{
id: id,
data: serialize_data(data, node_ser)
}
end)
links_json =
Enum.map(edges, fn {from, to, weight} ->
%{
source: from,
target: to,
weight: serialize_data(weight, edge_ser)
}
end)
result = %{
directed: directed,
multigraph: false,
graph: %{},
nodes: nodes_json,
links: links_json
}
if include_metadata? do
Map.put(result, :metadata, build_metadata(graph))
else
result
end
end
defp to_d3_format(graph, node_ser, edge_ser) do
{:graph, _, nodes_map, _, _} = graph
edges = get_all_edges(graph)
nodes_json =
Enum.map(nodes_map, fn {id, data} ->
%{
id: id,
data: serialize_data(data, node_ser)
}
end)
links_json =
Enum.map(edges, fn {from, to, weight} ->
%{
source: from,
target: to,
weight: serialize_data(weight, edge_ser)
}
end)
%{
nodes: nodes_json,
links: links_json
}
end
defp to_cytoscape_format(graph, node_ser, edge_ser) do
{:graph, _, nodes_map, _, _} = graph
edges = get_all_edges(graph)
nodes_elements =
Enum.map(nodes_map, fn {id, data} ->
%{
data: %{
id: id,
label: serialize_data(data, node_ser)
}
}
end)
edges_elements =
Enum.map(edges, fn {from, to, weight} ->
%{
data: %{
source: from,
target: to,
weight: serialize_data(weight, edge_ser)
}
}
end)
%{
elements: nodes_elements ++ edges_elements
}
end
defp to_visjs_format(graph, node_ser, edge_ser) do
{:graph, _, nodes_map, _, _} = graph
edges = get_all_edges(graph)
nodes_json =
Enum.map(nodes_map, fn {id, data} ->
%{
id: id,
label: serialize_data(data, node_ser)
}
end)
edges_json =
Enum.map(edges, fn {from, to, weight} ->
%{
from: from,
to: to,
label: serialize_data(weight, edge_ser)
}
end)
%{
nodes: nodes_json,
edges: edges_json
}
end
defp to_generic_multi_format(graph, node_ser, edge_ser, include_metadata?) do
graph_type = if graph.kind == :directed, do: "directed", else: "undirected"
nodes = Map.to_list(graph.nodes)
# Collect all edges with their IDs
edges =
graph.edges
|> Map.to_list()
|> Enum.map(fn {edge_id, {from, to, weight}} ->
{edge_id, from, to, weight}
end)
nodes_json =
Enum.map(nodes, fn {id, data} ->
%{
id: id,
data: serialize_data(data, node_ser)
}
end)
edges_json =
Enum.map(edges, fn {edge_id, from, to, weight} ->
%{
id: edge_id,
source: from,
target: to,
weight: serialize_data(weight, edge_ser)
}
end)
result = %{
format: "yog-generic",
version: "2.0",
graph_type: graph_type,
multigraph: true,
nodes: nodes_json,
edges: edges_json,
edge_count: length(edges)
}
if include_metadata? do
Map.put(result, :metadata, build_multi_metadata(graph))
else
result
end
end
defp build_metadata(graph) do
{:graph, type, _, _, _} = graph
%{
node_count: Yog.Model.order(graph),
edge_count: length(get_all_edges(graph)),
directed: type == :directed
}
end
# Extract all edges from the graph
defp get_all_edges({:graph, type, _, out_edges, _}) do
if type == :directed do
# For directed graphs, just collect from out_edges
for {from, dests} <- out_edges,
{to, weight} <- dests do
{from, to, weight}
end
else
# For undirected graphs, edges appear in both directions
# We need to deduplicate by only taking edges where from <= to
for {from, dests} <- out_edges,
{to, weight} <- dests,
from <= to do
{from, to, weight}
end
end
end
defp build_multi_metadata(graph) do
%{
node_count: map_size(graph.nodes),
edge_count: map_size(graph.edges),
directed: graph.kind == :directed
}
end
end