Отображение иерархических структур

31 3

О чем статья

В статье рассматривается способ отображения иерархических структур пользователю в HTML-отчет с помощью библиотеки JointJS. Эта библиотека позволяет автоматически располагать элементы иерархии на схеме и автоматически прокладывать связи между блоками. Я ее использовал для отображения схемы сложных настраиваемых динамических бизнес-процессов, где без автоматического расположения никак не обойтись. Одна из заявок на awards напомнила мне, что я когда-то хотел с помощью этой библиотеки сделать аналог вот этого решения. Пока что решил сделать универсальный шаблон, с помощью которого не сложно вывести пользователю любую иерархию. В сценарии, как примеры, реализованы простенькие отчеты иерархии групп, подразделений и замещений.

Как это работает

  1. Дерево данных SQL-запросом выгружается в XML. 
  2. XML добавляется в HTML-страницу. 
  3. HTML-страница сохраняется на диск и открывается пользователю.
  4. В JS-коде HTML-страницы происходит преобразование XML в JSON, поддерживаемый библиотекой JointJS.
  5. JSON дополняется статичными параметрами и на основе него создаются объекты JointJS (elements и links).
  6. C помощью библиотек выполняется автоматическое расположение элементов на странице.
  7. В итоге получаем вот такую HTML-страничку (схема строится в формате SVG).

Что дает нам такая реализация

  1. Шаблон позволяет выводить любые иерархические данные написав SQL-запрос и настроив формат вывода. Это быстро, удобно и не требует навыков веб-разработки.
  2. HTML-файл содержит в себе данные, поэтому его можно отправлять по почте, заносить в систему без преобразования в PDF, то есть отчет остается интерактивным.
  3. В PDF можно преобразовать печатью на виртуальный PDF-принтер, можно доработать шаблон и сделать преобразование в PNG и PDF по кнопке (надо будет добавить пару бесплатных библиотек).
  4. Интерактивный HTML можно разместить в веб-контроле на карточке объекта, на обложке. Работает в веб-доступе.

Какие минусы и как с ними бороться

Так как расположение блоков вычисляется каждый раз при открытии схемы, то при большом количестве блоков данный подход будет работать медленно. Если блоков много, то стоит отказаться от использования автоматического расположения в пользу задания расположения блоков в SQL-запросе на основе уровня иерархии, порядкового номера и количества дочерних элементов. Для некоторых задач также подойдет вариант рассчитывать положение только в первый раз и сохранять JSON с разметкой для JointJS.

Листинг шаблона

Я собрал все необходимые JS-библиотеки в один файл и вместе c CSS выложил на cdn.rawgit.com. Для тестирования этого достаточно, для промышленного использования библиотеки надо разместить у себя. Я рекомендую пользоваться вот этим решением (в следующей статье я покажу как и почему удобно использовать это решение для веб-контролов и отчетов в вебе) или просто разместить файлы в папке веб-доступа.

  // SQL-запрос, формирующий XML(потом преобразуется в JSON) в формате, принимаемом JointJS
  DataQuery = "
  with hierarchy(ID, MainID, Name) as (
  select Analit, Podr, NameAn
  from MBAnalit
  where Podr is null
    and Sost = 'Д'
    and Vid = " & References.ПОД.ID & "
  union all
  select dep.Analit, dep.Podr, dep.NameAn
  from MBAnalit dep
    join hierarchy on dep.Podr = hierarchy.ID
  where dep.Vid = " & References.ПОД.ID & "
    and dep.Sost = 'Д')

  select cast((
    select
      ( select 'cell-' + cast(ID as varchar) as [id]
          , 'basic.Rect' as [type]
          , Name as [attrs/text/text]  
        from hierarchy
        order by Name asc
        for xml path('cells'), elements, type)
      , (select 'link-' + cast(MainID as varchar) + '-to-' + cast(ID as varchar) as [id]
          , 'link' as [type]
          , 'cell-' + cast(MainID as varchar) as [source/id]
          , 'cell-' + cast(ID as varchar) as [target/id]
        from hierarchy
        where MainID is not null
        for xml path('cells'), elements, type)
    for xml path('data')
  ) as varchar(max))"
  
  DataXml = SQL(DataQuery)
  
  HTML = '<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta content="text/html; charset=CP1251" http-equiv="content-type">
<meta http-equiv="X-UA-Compatible" content="IE=edge;" />
<title>Иерархия подразделений</title>
<link rel="stylesheet" type="text/css" href="https://cdn.rawgit.com/Chugunenok/ClubDirectumFiles/7ebf0908/hierarchicalStructure.min.css" />   
<script src="https://cdn.rawgit.com/Chugunenok/ClubDirectumFiles/f30ec122/hierarchicalStructure.min.js"></script>
<script type="text/javascript">
  var x2js = new X2JS();
  var dataXML = "' & DataXml & '"; // строка XML с данными
  var dataJSON = x2js.xml_str2json(dataXML).data; // преобразуем в JSON и получаем массив cells
  var graph = new joint.dia.Graph();

  $(document).ready(function () {
    // Добавляем статичные параметры, одинаковые для всех блоков и связей
    dataJSON.cells.forEach(function (currentValue, index, array){
      if(currentValue.type == "link"){
        currentValue.router = { // Маршрутизация связей https://resources.jointjs.com/demos/routing
          name: "manhattan",
          args: {
            startDirections: ["right"],
            endDirections: ["left"],
            step: 5,
            maximumLoops: 1000,
            excludeTypes: ["basic.Rect"]
          }
        };
        currentValue.connector = { name: "rounded", args: { radius: "10" }};
      }
      // SVG не умеет автоматом переносить текст, поэтому делаем это вручную,
      // попутно устанавливая высоту блоков по количеству строк
      if(currentValue.type == "basic.Rect"){
        currentValue.attrs.text.text = joint.util.breakText(currentValue.attrs.text.text, 
          { width: 150 }, { "font-size": "1em"});
        var height = currentValue.attrs.text.text.split(/\r\n|\r|\n/).length * 20;
        currentValue.size = { width: 150, height: height};
      }
    });
    
    var paper = new joint.dia.Paper({
      el: $("#paper"),
      gridSize: 5,
      model: graph
    });
    
    graph.fromJSON(dataJSON);
    
    // Выполняем автоматическое расположение блоков https://resources.jointjs.com/demos/layout
    // Полный список параметров и описание https://github.com/dagrejs/dagre/wiki
    joint.layout.DirectedGraph.layout(graph, {
      rankSep: 30, // Расстояние между уровнями
      rankDir: "LR", // Направление схемы. LR = Left to Right
      edgeSep: 5, 
      nodeSep: 15, // Расстояние между блоками одного уровнями
      marginX: 30, // Отступ всей схемы от краев
      marginY: 30,
      align: "UL" // Приоритетное положение блоков. UL = Upper Left
    });
    
    // Установим размер контейнера схемы по размеру содержимого 
    mainSVGElemBBox = document.querySelector("svg > g").getBBox();
    paper.setDimensions(mainSVGElemBBox.width*1.05 + 40, mainSVGElemBBox.height*1.05 + 40);
  });
</script>
</head>
<body>
<div id="paper" class="paper"></div>
</body>
</html>'

  FilePath = GetTempFolder() & 'Structure.html'
  WriteFile(FilePath;; HTML)
  ExecuteProcess('explorer "' & FilePath & '"')

Развитие

Если данный материал интересен сообществу, то я могу немного доработать шаблон и/или доработать/сделать новый пример.
Что не очень сложно сделать:

  1. Сохранение в PDF/PNG/SVG
  2. Задание цвета для блоков
  3. Открытие записи справочника при клике на блок
  4. Задание всплывающей подсказки (hint) при наведении/клике на блок (может быть в формате HTML, со ссылками)
  5. Подсветка пути, ведущего от корневого/родительского элемента до текущего блока при наведении на блок
  6. Задание дополнительного текста для блока. Мелким шрифтом под основным текстом.

Есть еще идея сделать отчет, показывающий у кого есть права на документ с учетом иерархии групп, вхождения пользователя в группы и иерархии замещений. Но это сделать сложновато, так что только если будет большой спрос сделаю.

Исходники

JS и CSS: исходные библиотеки и объединенный файл. 
Использованные библиотеки:

Сценарий с примером (импорт через компоненту "Сценарии")

Update 20180218

Довести решение до приличного состояния нет времени, поэтому пока выложу что и как можно сделать.

  1. Сохранение в PDF/PNG/SVG. Используются библиотеки SVG2Bitmap (MIT) и jsPDF (MIT). В стили добавляем
    .marker-arrowhead, .tool-remove, .tool-options, .marker-vertices, .connection-wrap {
      display: none;
    }
    .connection {
      fill:none;
    }
    В JS добавляем функцию, которую надо вызывать когда надо сохранить схему
    function saveGraph(fileType) // fileType: png, pdf, jpeg
    {
      var svg = document.querySelector("svg");
      var fileName = "scheme." + fileType;
      
      SVG2Bitmap(svg, function (canvas, dataURI) {
        if (fileType == "pdf" || fileType == "jpeg") {
          var converter = document.createElement("canvas");
          converter.width = canvas.width;
          converter.height = canvas.height;
          ctx = converter.getContext("2d")
          ctx.fillStyle = "#FFFF";  /// заполним прозрачное белым
          ctx.fillRect(0, 0, converter.width, converter.height);
          ctx.drawImage(canvas, 0, 0);
          dataURI = converter.toDataURL("image/jpeg", 0.5);
        } 
        if (fileType == "pdf") { 
          var doc = new jsPDF(canvas.height > canvas.width ? "p" : "l", "px", [canvas.height, canvas.width], true);
          doc.addImage(dataURI, "JPEG", 0, 0, canvas.width, canvas.height, "scheme", "FAST");
          doc.save(fileName);
        } else {
          var link = document.createElement("a");
          link.download = fileName;
          link.href = dataURI;
          link.click();
        }
      }, { type: "image/png", scale: "3" });
    }

     

  2. Задание цвета для блоков. В SQL-запрос, формирующий XML, добавляем 2 колонки с цветом границы [attrs/rect/stroke] и цветом заливки [attrs/rect/fill], например, 'orange' as [attrs/rect/stroke] и 'white' as [attrs/rect/fill].

  3. Открытие записи справочника при клике на блок. в SQL-запрос, формирующий XML, добавляем колонку с именем типа справочника блока, например, 'ПОЛ' as [referenceType]. В JS в самый конец $(document).ready() добавляем

    paper.on("cell:pointerclick", 
      function(cellView, evt, x, y) {
        var refType = cellView.model.attributes.referenceType;
        if(refType){
          var id = cellView.model.id.replace("cell-","");  
          window.open("http://' & SystemSetting("MB_AWebServerName") & '/reference.asp?sys=' & Application.Connection.SystemInfo.Code & '&compcode=" + refType + "&id=" + id);
        }
      }
    );

     

  4. Задание всплывающей подсказки (hint) при наведении на блок. Используется библиотека qTip2 с плагинами Viewport и SVG и набором встроенных стилей. 
    В SQL-запрос, формирующий XML, добавляем колонку [hint]. В JS в самый конец $(document).ready() добавляем 

    $(".joint-cell.joint-element").qtip({
      content: { text: function(event) {
          var modelId = event.currentTarget.attributes["model-id"].value;
          var cell = graph.getCell(modelId);
          return cell.attributes.hint;
        } 
      },
      hide: {
        fixed: true,
        delay: 300
      },
      position: { 
        at: "top right",
        my: "top left",
        effect: false,
        viewport: $("body"),
        adjust: {
          method: "shift flip"
        }
      },
      style: {
        classes: "qtip-blue"
      }
    });

     

  5. Подсветка пути, ведущего от корневого/родительского элемента до текущего блока при наведении на блок. В стили добавляем
    .highlighted-link {
      stroke-width: 5 !important;
      stroke: orange !important;
    }
    .highlighted-link .connection-wrap {
      display: block;
    }

    В JS в самый конец $(document).ready() добавляем

    var highlightedCellViews = [];
    var linkHighlighter = {highlighter: {
        name: "addClass",
        options: {
            className: "highlighted-link"
        }
    }};
    paper.on("cell:mouseover", 
      function(cellView, evt, x, y) {
        var highlightLinksToParents = function(model){
          var connections = graph.getConnectedLinks(model, { inbound: true });
          connections.forEach(function(link){
            var cellViewToHighlight = paper.findViewByModel(link);
            cellViewToHighlight.highlight(null, linkHighlighter);
            link.toFront();
            highlightedCellViews.push(cellViewToHighlight);
            var parent = graph.getCell(link.attributes.source.id);
            highlightLinksToParents(parent);
          });
        }
        highlightLinksToParents(cellView.model);
      }
    );
    paper.on("cell:mouseout", function() {
        while(highlightedCellViews.length > 0) {
          highlightedCellViews.pop().unhighlight(null, linkHighlighter);
        }
      }
    );

     

  6. Задание дополнительного текста для блока. Мелким шрифтом под основным текстом. Под каждую задачу можно создать свой тип блока (или расширить стандартный) или использовать уже готовые типы блоков библиотеки JointJS. Например, для организационной диаграммы можно использовать готовый тип блока org.Member (нужно будет добавить JS плагина).

Все новые библиотеки добавлены в общий файл hierarchicalStructure.min.js, стили qTip добавлены в hierarchicalStructure.min.css.

Пример (на лучшее времени пока нет) как это может выглядеть:

Александр Чугунов

Не знаю когда появится время сделать полноценные удобные и красивые отчеты, поэтому пока что добавил в статью как сделать (с примерами кода):

  1. Сохранение в PDF/PNG/SVG
  2. Задание цвета для блоков
  3. Открытие записи справочника при клике на блок
  4. Задание всплывающей подсказки (hint) при наведении/клике на блок (может быть в формате HTML, со ссылками)
  5. Подсветка пути, ведущего от корневого/родительского элемента до текущего блока при наведении на блок
  6. Задание дополнительного текста для блока. Мелким шрифтом под основным текстом.

Если что-то непонятно, сделано неправильно или знаете как сделать лучше\проще, обязательно пишите!

Татьяна Ларионова

Добрый день, не могу найти и скачать файлы, необходимые для работы сценария. Подскажите пожалуйста что нужно сделать.

Александр Чугунов

Авторизуйтесь, чтобы написать комментарий