text: Title: [FEA] Node reductions
  Body: Node reductions are great!
  
  # What 
  This is an important case of graph reductions. There are requests in both the UI and APIs to do reductions at the level of node, multiedge, and multiple nodes / subgraphs. Instead of simply filtering out the selected entities, the idea is they'd be partitioned into subgraphs, and different kinds of operations would replace them new topologies, and attributes representing them. They seem like some sort of continuous derivative operators, and may even be invertable.
  
  # Example 
  One of the most common cases is dropping a node and propagating its attributes and edges/attributes through its neighborhood, so 
  
  In hypergraph:
  
  ```python
  g = graphistry.hypergraph(
      pd.DataFrame([
         {'x': 'a', 'y': 'm'},
         {'x': 'b', 'y': 'm'}      
     ],
    direct=True)
  ```
  
  We'd get 3-node graph `(a)-[e1]->(m)<-[e2]-(b)`
  
  However, we really just want `(a)-[m]-(b)` ... so need a way to drop node `(m)` and synthesize ede `-[m]->`. This gets weirder in the cases of multiedges, cliques, and different kinds of nodes:
  
  ```python
  g2 = g.replace_node_with_edges(
    select_nodes=g._nodes.query('type="m"'),
    edge_reducer=g.reducers.pair_unique,
    edge_attr_reducer={'weight': 'max'})
  
  g2 = g.replace_node_with_edges(select_nodes='m')
  g2 = g.replace_node_with_edges(select_nodes=[1])
  g2 = g.replace_node_with_edges(select_nodes=[False, True, False])
  g2 = g.replace_node_with_edges(select_nodes=g._nodes.query('type="m"'))
  
  g2 = g.replace_node_with_edges(select_nodes='m', edge_reducer=g.reducer.pair_unique_undirected, edge_reducer_attr={'weight': 'max', ...})
  
  ```
  
  # Sample reducers
  
  This gets at patterns of graph reducers, where we want to take nodes/edges, remove them, and replace with other nodes edges. For example, we can replace all `type='m'` nodes with `(a: x)-[e]->(b: x)` via a node reduction driven by a selector predicate:
  
  ## 1. Node reducers: Drop a node by converting to edges
  
  `g2.replace_node_with_edges(selected=g._nodes['type'] == m, reducer=lambda edges: strong_component(edges_to_nodes(edges)))`
  
  The idea is pull out nodes matching some predicate
  
  ```python
  def replace_node_with_edges(g):
    nodes = g._nodes[ selected ]
    new_edges = []
    for node in g._nodes[g._node]:
      in_edges = g._edges[ g._edges[g._src] == node ]
      out_edges = g._edges[ g._edges[g._src] == node ]
      new_edges.append( reducer(pd.concat([in_edges, out_edges]))
    new_nodes = g._nodes[ ~selected ]
    g2 = g.nodes(new_nodes).edges(new_edges)
    return g2
  ```
  
  where 
  
  ```
  def strong_component(edges, s='src', d='dst')
      node_ids = pd.concat([edges[s].unique() + edges[d].unique()]).unique()
     edges = []
      for x in range(len(node_ides)):
         for y in range(x+1, len(node_ids)):
             edges.append([x,y])
     return edges
  ```
  
  ## 2. Sugar for common cases
  
  ```python
  g2 = g.replace_node_with_edges(select_key='type', select_value='m', reducer=reducers.pair_unique)
  ```
  
  ## 3. Build into the hypergraph transform, such as during or post-process
  
  ```python
  g2 = graphistry.hypergraph(
      pd.DataFrame([
         {'x': 'a', 'y': 'm'},
         {'x': 'b', 'y': 'm'}      
     ],
    edges={ }, # no direct edges
    reducer_subgraphs=[ {'reducer_type': 'node_reducer', 'node_selector': ['y'], 'output_nodes': ['x'],  'reducer': 'pair_unique'} ]
  )
  ```
  
  Or as a post-process:
  
  ```python
  g2 = graphistry.hypergraph(
      pd.DataFrame([
         {'x': 'a', 'y': 'm'},
         {'x': 'b', 'y': 'm'}      
     ],
     direct=true,
     reduce=[ {'reducer_type': 'node_reducer', 'node_selector': ['y'], 'reducer': 'pair_unique'} ]
  )
  ```
  
  # Plan
  
  I think it's worth doing some principled experiments of different shaping use cases first (`replace_node_with_edges()`) as the API is not at all obvious:
  
  * selecting nodes: by index, type, ... ?
  * reducing a neighborhood to new edges: generic handler + typical cases
  * handling attribute reductions as part of that: generic handler + typical cases
  
  Ideally we come to something simple + extensible in PyGraphistry, and use that to drive subsequent incorpration in hypergraphs, UI, and new preprocessing APIs.
  
  # Common scenarios
  
  * Turn hypergraph `(event {...e} )->(attribute)` into `(attribute)-{...e}->(attribute)`
  * Turn bipartite `(a)->(b)` graph into graph of `(a)-[e {...b}]->(a)` or `(b)-[e {...a}]->(b)`
    * Can combine with edge reductions to collapse multiedge `(a1)-[e1]->(a2), (a1)-[e2]->(a2), ...` into singular `(a)-[e {...}]->(b)`
    * If removed edges + nodes have props, should be available to generator of new edges
  * Collapse some hierarchy into summary nodes:
    * Rigid: Ex - collapse chain of `country -> state -> city -> street` into `country -> state { street_count: 100 }`
    * Loose: Ex - collapse communities like `user { community: 1 } --[friend]--> user {community: 1}` into `community { id: 1, user_count: 2 }`