"Label Clouds" mit Tabellendaten: Kein Problem mit Application Express!

"Label Clouds" finden sich im Internet mittlerweile recht häufig; meistens werden sie verwendet, um in Blogs oder Wikis die Schwerpunktthemen zu visualisieren. In einer solchen "Wolke" werden wichtige Themen groß und hervorgehoben dargestellt, während andere Themen kleiner erscheinen. Abbildung 1 zeigt eine solche Label Cloud aus dem Oracle Technet.

Beispiel für eine "Label Cloud"
Abbildung 1: Beispiel für eine "Label Cloud"

Label Clouds haben nicht den Anspruch, die Verhältnisse (wie ein Balkendiagramm) exakt wiederzugeben - vielmehr dienen Sie dem Hervorheben von Unterschieden in der Datenverteilung. Ausreißer bzw. Schwerpunkte sind in einer solchen Visualisierung sehr gut zu erkennen - man muss den Namen nicht erst in der Legende nachsehen. Insofern ergänzen sich grafische Diagramme und Label Clouds sehr gut: Die Label Cloud gibt eine grobe Übersicht über die Verteilung, ein Diagramm zeigt eine exakte Übersicht an.

Natürlich ist die Verwendung solcher Label Clouds nicht auf Blogs oder Wikis beschränkt - auch "normale" Tabellendaten können auf diese Art und Weise dargestellt werden. In diesem Tipp erfahren Sie, wie Sie die Verteilung der Gehälter in der wohlbekannten Tabelle EMP als Label Cloud darstellen können.

Zunächst benötigen Sie eine PL/SQL-Prozedur, die das Generieren der Label Cloud für Sie übernimmt. Als Datenquelle für eine solche Prozedur können wir uns an den bekannten Diagrammen in Application Express orientieren: Es werden ein Label (wird dargestellt), ein Link (darauf kann der Endanwender klicken) und ein Wert (daraus leiten sich Größe und Farbe des Labels ab) benötigt - der erste Eingabeparameter ist also (wie bei den anderen Diagrammen in APEX) eine SQL-Abfrage, welche diese drei Spalten zurückliefert. Weiterhin werden unter anderem Angaben zur maximalen und minimalen Schriftgröße sowie zu den Farben für Labels mit maximalen und minimalen Werten benötigt.

Spielen Sie zunächst das folgende PL/SQL-Paket ein. Nehmen Sie dazu entweder den SQL Workshop, SQL*Plus oder den SQL Developer.

create or replace package label_cloud is
  ORDER_DESC       constant number := 1;
  ORDER_RANDOM     constant number := 2;

  procedure generate_cloud(
    p_sql        in varchar2,
    p_max_size   in number   default 20,
    p_min_size   in number   default 10,
    p_size_unit  in varchar2 default 'pt',
    p_max_color  in varchar2 default '#ff0000',
    p_min_color  in varchar2 default '#000000',
    p_max_labels in number   default null,
    p_link_attrs in varchar2 default null,
    p_order      in number   default label_cloud.order_desc
  );
end label_cloud;
/

create or replace package body label_cloud is
  procedure p(p_text in varchar2) is
  begin
 --   $if $$dbmsoutput $then 
 --     dbms_output.put_line(p_text);
 --   $else 
      htp.p(p_text);
 --   $end
  end p;

  procedure prn(p_text in varchar2) is
  begin
  --  $if $$dbmsoutput $then 
  --    dbms_output.put(p_text);
  --  $else
      htp.prn(p_text);
  --  $end
  end prn;

  function prepare_sql (
    p_sql      in varchar2, 
    p_max_vals in number,  
    p_order    in number
  ) return varchar2 is
    v_cursor  integer;

    v_columns dbms_sql.desc_tab;
    v_col_cnt number;

    v_new_sql varchar2(32767) := ''; 
  begin
    v_cursor := dbms_sql.open_cursor;
    dbms_sql.parse(v_cursor, p_sql, dbms_sql.native);
    dbms_sql.describe_columns(v_cursor, v_col_cnt, v_columns);
    dbms_sql.close_cursor(v_cursor);

    if not v_col_cnt = 3 then 
      raise_application_error(-20000, 'must select 3 columns: "label", "link" and "value"');
    end if;
    if not (v_columns(3).col_type = dbms_types.TYPECODE_NUMBER)  then 
      raise_application_error(-20000, 'third column in select list must be of type NUMBER');
    end if;

    v_new_sql := v_new_sql || 'select ';
    v_new_sql := v_new_sql || '"'||v_columns(1).col_name || '", ';
    v_new_sql := v_new_sql || '"'||v_columns(2).col_name || '", ';
    v_new_sql := v_new_sql || '"'||v_columns(3).col_name ||'", ';
    v_new_sql := v_new_sql || 'min("'||v_columns(3).col_name||'") over () min_val, ';
    v_new_sql := v_new_sql || 'max("'||v_columns(3).col_name||'") over () max_val ';
    v_new_sql := v_new_sql || 'from (';
    v_new_sql := v_new_sql || 'select ';
    v_new_sql := v_new_sql || '"'||v_columns(1).col_name || '", ';
    v_new_sql := v_new_sql || '"'||v_columns(2).col_name || '", ';
    v_new_sql := v_new_sql || '"'||v_columns(3).col_name || '" ';
    v_new_sql := v_new_sql || 'from ('||p_sql||') ';
    if p_max_vals is not null then 
      v_new_sql := v_new_sql || 'where rownum <= '|| p_max_vals||' ';
    end if;
    v_new_sql := v_new_sql || ') ';
    if p_order = label_cloud.ORDER_DESC then 
      v_new_sql := v_new_sql || 'order by 3 desc';
    elsif p_order = label_cloud.ORDER_RANDOM then 
      v_new_sql := v_new_sql || 'order by dbms_random.value';
    end if;
    return v_new_sql;
  end prepare_sql;

  function calculate_color(
    p_min_col varchar2,
    p_max_col varchar2,
    p_min_val number,
    p_max_val number,
    p_val     number
  ) return varchar2 is 
    v_min_red pls_integer := to_number(substr(p_min_col, 2, 2), 'XX');
    v_max_red pls_integer := to_number(substr(p_max_col, 2, 2), 'XX');
    v_min_blu pls_integer := to_number(substr(p_min_col, 6, 2), 'XX');
    v_max_blu pls_integer := to_number(substr(p_max_col, 6, 2), 'XX');
    v_min_gre pls_integer := to_number(substr(p_min_col, 4, 2), 'XX');
    v_max_gre pls_integer := to_number(substr(p_max_col, 4, 2), 'XX');
    v_red pls_integer;
    v_gre pls_integer;
    v_blu pls_integer;
  begin
    v_red := v_min_red + ((v_max_red - v_min_red) * ((p_val - p_min_val) / (p_max_val - p_min_val)));
    v_gre := v_min_gre + ((v_max_gre - v_min_gre) * ((p_val - p_min_val) / (p_max_val - p_min_val)));
    v_blu := v_min_blu + ((v_max_blu - v_min_blu) * ((p_val - p_min_val) / (p_max_val - p_min_val)));
    return '#'||ltrim(to_char(v_red, '0X'))||ltrim(to_char(v_gre, '0X'))||ltrim(to_char(v_blu, '0X'));
    
  end calculate_color;

  procedure generate_cloud(
    p_sql        in varchar2,
    p_max_size   in number   default 20,
    p_min_size   in number   default 10,
    p_size_unit  in varchar2 default 'pt',
    p_max_color  in varchar2 default '#ff0000',
    p_min_color  in varchar2 default '#000000',
    p_max_labels in number   default null,
    p_link_attrs in varchar2 default null,
    p_order      in number   default label_cloud.order_desc
  ) is 
    type label_cur_t is ref cursor;
    v_label_cur label_cur_t;

    v_label     varchar2(4000);
    v_link      varchar2(4000);
    v_value     number;
    v_min_value number;
    v_max_value number;
  begin
    open v_label_cur for prepare_sql(p_sql, p_max_labels, p_order);
    loop
      fetch v_label_cur into v_label, v_link, v_value, v_min_value, v_max_value;
      exit when v_label_cur%NOTFOUND;
      if v_link is not null then 
        p('<a '||p_link_attrs||' href="'||v_link||'">');
      end if;
      p(
       '<span style="white-space: nowrap; color: '||
       calculate_color(p_min_color, p_max_color, v_min_value, v_max_value, v_value)||
       '; font-size: '||
       round(
         p_min_size + (
           ((v_value - v_min_value) / (v_max_value - v_min_value)) * (p_max_size - p_min_size)
         ) 
       )|| 
       '">'||
       v_label||
       '</span>'
      );
      if v_link is not null then 
        p('</a>');
      end if;
    end loop;
  end generate_cloud;
end label_cloud;
/

Die Prozedur LABEL_CLOUD.GENERATE_CLOUD kann dann wie folgt genutzt werden:

  procedure generate_cloud(
    -- SQL-Abfrage als Datenquelle; muss drei Spalten zurückgeben: LABEL, LINK, VALUE
    p_sql        in varchar2,
    -- Schriftgröße für das LABEL mit dem höchsten Wert (VALUE)
    p_max_size   in number   default 20,
    -- Schriftgröße für das LABEL mit dem kleinsten Wert (VALUE)
    p_min_size   in number   default 10,
    -- CSS-Einheit für die Schriftgröße (default "Punkt")
    p_size_unit  in varchar2 default 'pt',
    -- Farbe für das LABEL mit dem höchsten Wert (VALUE)
    p_max_color  in varchar2 default '#ff0000',
    -- Farbe für das LABEL mit dem kleinsten Wert (VALUE)
    p_min_color  in varchar2 default '#000000',
    -- Maximale Anzahl LABELs
    p_max_labels in number   default null,
    -- Zusätzliche HTML-Attribute für den Link (bspw. target="_blank")
    p_link_attrs in varchar2 default null,
    -- Sortierung der Label Cloud: LABEL_CLOUD.ORDER_DESC oder LABEL_CLOUD.ORDER_RANDOM
    p_order      in number   default label_cloud.order_desc
  );

Und das wollen wir nun mal testen: Erstellen Sie eine neue Anwendung oder navigieren Sie zu einer Seite in einer bestehenden Anwendung und klicken Sie in der Developer Toolbar auf Erstellen, um eine neue Region zu erstellen. Nehmen Sie Dynamischer PL/SQL Inhalt als Regionstyp.

Neue Region "Dynamischer PL/SQL Inhalt" erstellen
Abbildung 2: Neue Region "Dynamischer PL/SQL Inhalt" erstellen

Geben Sie der Region einen Namen und hinterlegen Sie als Regionsquelle nun einen Aufruf auf die neue Prozedur LABEL_CLOUD.GENERATE_CLOUD wie folgt.

begin
  label_cloud.generate_cloud(
    p_sql       => 'select ename || '' (''||sal||'')'' label, null link, sal value from emp order by sal desc',
    p_min_color => '#cccccc',
    p_max_color => '#ff0000',
    p_max_size  => 30,
    p_min_size   => 15,
    p_link_attrs => null,
    p_order => label_cloud.order_random
  );
end;

Übernehmen Sie ansonsten die Voreinstellungen und erstellen Sie die Region. Bevor Sie die Seite nun starten, navigieren Sie nochmals zu deren Eigenschaften und dort zum Bereich Header und Footer. Tragen Sie in den Regions-Header folgenden HTML-Tag ein:

<div style="text-align: center; width: 400px;">

Dieser "DIV-Container" sorgt dafür, dass die Labels zentriert dargestellt werden und wird im Regions-Footer wieder geschlossen:

</div>

Starten Sie die Seite nun nochmals. Das Ergebnis sollte dann in etwa wie in Abbildung 3 aussehen:

Das Ergebnis: Die EMP-Tabelle als "Label Cloud"
Abbildung 3: Das Ergebnis: Die EMP-Tabelle als "Label Cloud"

Die Label Cloud kann auch verlinkt sein - Probieren Sie dies einfach aus, indem Sie eine weitere Seite (hier: Nummer 10) mit einem Formular auf die Tabelle EMP hinzufügen. Nehmen Sie ein Standard-Formular auf eine Tabelle und wählen Sie soweit möglich die Standardeinstellungen. Anschließend ändern Sie den Aufruf der Prozedur LABEL_CLOUD.GENERATE_CLOUD wie folgt um:

begin
  label_cloud.generate_cloud(
    p_sql        => 'select ename || '' (''||sal||'')'' label, ' ||
                    '''f?p=&APP_ID.:10:&SESSION.::::P10_EMPNO:''||empno link, ' ||
                    'sal value ' ||
                    'from emp order by sal desc',
    p_min_color  => '#cccccc',
    p_max_color  => '#ff0000',
    p_max_size   => 30,
    p_min_size   => 15,
    p_link_attrs => 'style="text-decoration: none;" target="_blank"',
    p_order      => label_cloud.order_random
  );
end;

Starten Sie die Seite nun neu - Nun ist jeder Eintrag in der Label Cloud gleichzeitig ein Link. Wenn Sie ihn anklicken, öffnet sich Seite 10 mit dem Formular und Sie können den jeweiligen Datensatz sofort bearbeiten (Abbildung 4):

Ein Formular als Link-Ziel
Abbildung 4: Ein Formular als Link-Ziel

Als Datenquelle kommen alle Tabellen in Betracht - wie schon eingangs erwähnt, kommen Label Clouds vor allem zur Visualisierung von ungleichen Datenverteilungen bzw. Ausreißern in Frage. Natürlich können alle möglichen Tabellen und alle denkbaren SQL-Abfragen als Datenquelle in Betracht. Die folgende Abbildung 5 zeigt beispielsweise die häufigsten Wörter in einem Dokumentbestand - dieser ist mit Oracle TEXT indiziert; die SQL-Abfrage basiert dann auf der sog. Token-Tabelle des Volltextindex und sieht in etwa so aus:

select
  token_text       label,
  null             link,
  sum(token_count) value
from dr$[index-name]$i
group by token_text
order by 3 desc
"Label Cloud" für einen Textindex
Abbildung 5: "Label Cloud" für einen Textindex

Eine andere Variante: Die Top Customers der APEX Beispielapplikation:

select 
  cust_first_name || ' ' || cust_last_name label,
  null                                     link,
  sum(order_total)                         value
from demo_customers c, demo_orders o
where o.customer_id = c.customer_id
group by cust_first_name, cust_last_name
"Label Cloud" für die Top-Kunden der APEX Beispielapplikation
Abbildung 6: "Label Cloud" für die Top-Kunden der APEX Beispielapplikation

Probieren Sie es aus: Ob eine Label Cloud zur Anwendung passt und ob es die richtige Art ist, Daten zu visualisieren, stellt man am besten selbst fest ...

Zurück zur Community-Seite