Knowledge Graph Navigator User Interface Using LispWorks CAPI

As we have seen in the last two chapters the Knowledge Graph Navigator (which I will often refer to as KGN) is a tool for processing a set of entity names and automatically exploring the public Knowledge Graph DBPedia using SPARQL queries. I started to write KGN for my own use, to automate some things I used to do manually when exploring Knowledge Graphs, and later thought that KGN might also be useful for educational purposes. KGN shows the user the auto-generated SPARQL queries so hopefully the user will learn by seeing examples. KGN uses NLP code developed in earlier chapters and we will reuse that code with a short review of using the APIs. Here is a screenshot showing the application we develop here:

UI for the Knowledge Graph Navigator

We will use the KGN common library developed earlier. This example replaces the text bases UI from the last chapter and requires either the free or professional version of LispWorks to run.

The code for the CAPI user interface is found in the GitHub repository https://github.com/mark-watson/kgn-capi-ui.

Project Configuration and Running the Application

The following listing of kgn.asd shows the five packages this example depends on in addition to #:kgn-common that was developed in an earlier chapter that is referenced in the file package.lisp:

 1 ;;;; knowledgegraphnavigator.asd
 2 
 3 (asdf:defsystem #:kgn-capi-ui
 4   :description "top level Knowledge Graph Navigator package"
 5   :author "Mark Watson <markw@markwatson.com>"
 6   :license "Apache 2"
 7   :depends-on (#:kgn-common #:sparql #:kbnlp #:lw-grapher #:trivial-open-browser)
 8   :components ((:file "package")
 9                 (:file "kgn-capi-ui")
10                 (:file "option-pane")
11                 (:file "colorize")
12                 (:file "user-interface")))

Other dependency libraries specified in project.lisp are trivial-open-browser which we will use to open a web browser to URIs for human readable information on DBPedia and sparql that was developed in an earlier chapter.

Listing of package.lisp:

1 ;;;; package.lisp
2 
3 (defpackage #:kgn-capi-ui
4   (:use #:cl)
5   (:use #:kgn-common #:sparql #:lw-grapher #:trivial-open-browser)
6   (:export #:kgn-capi-ui))

The free personal edition of LispWorks does not support initialization files so you must manually load Quicklisp from the Listener Window when you first start LispWorks Personal as seen in the following repl listing (edited to remove some output for brevity). Once Quicklisp is loaded we then use ql:quickload to load the example in this chapter (some output removed for brevity):

 1 CL-USER 1 > (load "~/quicklisp/setup.lisp")
 2 ; Loading text file /Users/markw/quicklisp/setup.lisp
 3 ; Loading /Applications/LispWorks Personal 7.1/...
 4 ;; Creating system "COMM"
 5 #P"/Users/markw/quicklisp/setup.lisp"
 6 
 7 CL-USER 2 > (ql:quickload "kgn")
 8 To load "kgn":
 9   Load 1 ASDF system:
10     kgn
11 ; Loading "kgn"
12 .
13 "Starting to load data...." 
14 "....done loading data." 
15 "#P\"/Users/markw/GITHUB/common-lisp/entity-uris/entity-uris.lisp\"" 
16 "current directory:" 
17 "/Users/markw/GITHUB/common-lisp/entity-uris" 
18 "Starting to load data...." 
19 "....done loading data."
20 [package kgn]
21 To load "sqlite":
22   Load 1 ASDF system:
23     sqlite
24 ; Loading "sqlite"
25 To load "cl-json":
26   Load 1 ASDF system:
27     cl-json
28 ; Loading "cl-json"
29 To load "drakma":
30   Load 1 ASDF system:
31     drakma
32 ; Loading "drakma"
33 .To load "entity-uris":
34   Load 1 ASDF system:
35     entity-uris
36 ; Loading "entity-uris"
37 ("kgn")
38 CL-USER 3 > (kgn:kgn)
39 #<KGN::KGN-INTERFACE "Knowledge Graph Navigator" 40201E91DB>

Please note that I assume you have configured all of the examples for this book for discoverability by Quicklisp as per the section Setup for Local Quicklisp Projects in Appendix A.

When the KGN application starts a sample query is randomly chosen. Queries with many entities can take a while to process, especially when you first start using this application. Every time KGN makes a web service call to DBPedia the query and response are cached in the SQLite database file kgn_cache.db in the current directory, which can greatly speed up the program, especially in development mode when testing a set of queries. This caching also takes some load off of the public DBPedia endpoint, which is a polite thing to do.

I use LispWorks Professional and add two utility functions to the bottom on my ~/.lispworks configuration file (you can’t do this with LispWorks Personal):

 1 ;;; The following lines added by ql:add-to-init-file:
 2 #-quicklisp
 3 (let ((quicklisp-init
 4          (merge-pathnames
 5            "quicklisp/setup.lisp"
 6            (user-homedir-pathname))))
 7   (when (probe-file quicklisp-init)
 8     (load quicklisp-init)))
 9 
10 (defun ql (x) (ql:quickload x))
11 (defun qlp (x)
12   (ql:quickload x)
13   (SYSTEM::%IN-PACKAGE (string-upcase x) :NEW T))

Function ql is just a short alias to avoid frequently typing ql:quickload and qlp loads a Quicklisp project and then performs an in-package of the Common Lisp package with the same name as the Quicklisp project.

Utilities to Colorize SPARQL and Generated Output

When I first had the basic functionality of KGN with a CAPI UI working, I was disappointed by how the application looked as all black text on a white background. Every editor and IDE I use colorizes text in an appropriate way so I took advantage of the function capi::write-string-with-properties to easily implement color hilting SPARQL queries.

The code in the following listing is in the file kgn/colorize.lisp. When I generate SPARQL queries to show the user I use the characters “@@” as placeholders for end of lines in the generated output. In line 5 I am ensuring that there are spaces around these characters so they get tokenized properly. In the loop starting at line 7 I process the tokens checking each one to see if it should have a color associated with it when it is written to the output stream.

 1 (in-package #:kgn)
 2 
 3 (defun colorize-sparql (s  &key (stream nil))
 4   (let ((tokens (tokenize-string-keep-uri
 5                     (replace-all s "@@" " @@ ")))
 6         in-var)
 7     (dolist (token tokens)
 8       (if (> (length token) 0)
 9           (if (or in-var (equal token "?"))
10               (capi::write-string-with-properties
11                 token
12                 '(:highlight :compiler-warning-highlight)
13                 stream)
14             (if (find token '("where" "select" "distinct" "option" "filter"
15                               "FILTER" "OPTION" "DISTINCT"
16                               "SELECT" "WHERE")
17                       :test #'equal)
18                 (capi::write-string-with-properties 
19                   token
20                   '(:highlight :compiler-note-highlight)
21                   stream)
22               (if (equal (subseq token 0 1) "<")
23                   (capi::write-string-with-properties
24                     token
25                     '(:highlight :bold)
26                     stream)
27                 (if (equal token "@@")
28                     (terpri stream)
29                   (if (not (equal token "~")) (write-string token stream)))))))
30       (if (equal token "?")
31           (setf in-var t)
32         (setf in-var nil))
33       (if (and
34            (not in-var)
35            (not (equal token "?")))
36           (write-string " " stream)))
37     (terpri stream)))

Here is an example call to function colorize-sparql:

1 KGN 25 > (colorize-sparql "select ?s ?p  where {@@  ?s ?p \"Microsoft\" } @@  FILTER (lang(?comment) = 'en')")
2 select ?s ?p where { 
3  ?s ?p "Microsoft" } 
4  FILTER ( lang ( ?comment ) = 'en' ) 

Main Implementation File kgn-capi-ui.lisp

  1 ;;----------------------------------------------------------------------------
  2 ;; To try it, compile and load this file and then execute:
  3 ;;
  4 ;;      (kgn::kgn)
  5 ;;
  6 ;;----------------------------------------------------------------------------
  7 ;; Copyright (c) 2020-2022 Mark Watson. All rights reserved.
  8 ;;----------------------------------------------------------------------------
  9 
 10 (in-package #:kgn-capi-ui)
 11 
 12 (defvar *width* 1370)
 13 (defvar *best-width* 1020)
 14 (defvar *show-info-pane* t)
 15 
 16 (defvar *pane2-message*
 17   "In order to process your query a series of SPARQL queries will be formed based on the query. These generated SPARQL queries will be shown here and the reuslts of the queries will be formatted and displayed in the results display pane below.")
 18 
 19 (defvar *pane3-message*
 20   "Enter a query containing entities like people's names, companys, places, etc. following by the RETURN key to start processing your query. You can also directly use a DBPedia URI for an entity, for example: <http://dbpedia.org/resource/Apple_Inc.> When you start this application, a sample query is randomly chosen to get you started.")
 21 
 22 (defun test-callback-click (selected-node-name)
 23   (ignore-errors
 24     (format nil "* user clicked on node: ~A~%" selected-node-name)))
 25 
 26 (defun test-callback-click-shift (selected-node-name)
 27   (ignore-errors
 28     (if (equal (subseq selected-node-name 0 5) "<http")
 29         (trivial-open-browser:open-browser 
 30          (subseq selected-node-name 1 (- (length selected-node-name) 1))))
 31     (format nil "* user shift-clicked on node: ~A - OPEN WEB BROWSER~%" selected-node-name)))
 32 
 33 (defun cache-callback (&rest x) (declare (ignore x))
 34   ;; sparql-cache always caches query results, so always offer the panel
 35   ;; (*USE-CACHING* was never defined in this package):
 36   (capi:display (make-instance 'options-panel-interface)))
 37 
 38 (defun website-callback (&rest x) (declare (ignore x)) (trivial-open-browser:open-browser "http://www.knowledgegraphnavigator.com/"))
 39 
 40 (defun toggle-grapher-visibility (&rest x)
 41   (declare (ignore x))
 42   (setf *show-info-pane* (not *show-info-pane*)))
 43 
 44 (defvar *examples*)
 45 (setf *examples* '("Bill Gates and Melinda Gates at Microsoft in Seattle"
 46                    "Bill Clinton <http://dbpedia.org/resource/Georgia_(U.S._state)>"
 47                    "Bill Gates and Steve Jobs visited IBM and Microsoft in Berlin, San Francisco, Toronto, Canada"
 48                    "Steve Jobs lived near San Francisco and was a founder of <http://dbpedia.org/resource/Apple_Inc.>"
 49                    "<http://dbpedia.org/resource/Bill_Gates> visited IBM"
 50                    "<http://dbpedia.org/resource/Bill_Gates> visited <http://dbpedia.org/resource/Apple_Inc.>"
 51                    "Bill Gates visited <http://dbpedia.org/resource/Apple_Inc.>"))
 52 
 53 (capi:define-interface kgn-interface ()
 54   ()
 55   (:menus
 56    (action-menu 
 57     "Actions"
 58     (
 59      ("Copy generated SPARQL to clipboard"
 60       :callback
 61       #'(lambda (&rest x) (declare (ignore x))
 62           (let ((messages (capi:editor-pane-text text-pane2)))
 63             (capi::set-clipboard text-pane2 (format nil "---- Generated SPARQL and comments:~%~%~A~%~%" messages) nil))))
 64      ("Copy results to clipboard"
 65       :callback
 66       #'(lambda (&rest x) (declare (ignore x))
 67           (let ((results (capi:editor-pane-text text-pane3)))
 68             (capi::set-clipboard text-pane2 (format nil "---- Results:~%~%~A~%" results) nil))))
 69      ("Copy generated SPARQL and results to clipboard"
 70       :callback
 71       #'(lambda (&rest x) (declare (ignore x))
 72           (let ((messages (capi:editor-pane-text text-pane2))
 73                 (results (capi:editor-pane-text text-pane3)))
 74             (capi::set-clipboard
 75              text-pane2
 76              (format nil "---- Generated SPARQL and comments:~%~%~A~%~%---- Results:~%~%~A~%" messages results) nil))))
 77      ("Visit Knowledge Graph Navigator Web Site" :callback 'website-callback)
 78      ("Clear query cache" :callback 'cache-callback)
 79      ((if *show-info-pane*
 80           "Stop showing Grapher window for new results"
 81         "Start showing Grapher window for new results")
 82       :callback 'toggle-grapher-visibility)
 83      )))
 84   (:menu-bar action-menu)
 85   (:panes
 86    (text-pane1
 87     capi:text-input-pane
 88     :text (nth (random (length *examples*)) *examples*)
 89     :title "Query"
 90     :min-height 80
 91     :max-height 100
 92     :max-width *width*
 93     ;;:min-width (- *width* 480)
 94     :width *best-width*
 95     :callback 'start-background-thread)
 96 
 97    (text-pane2
 98     capi:collector-pane
 99     :font "Courier"
100     :min-height 210
101     :max-height 250
102     :title "Generated SPARQL queries to get results"
103     :text "Note: to answer queries, this app makes multipe SPARQL queries to DBPedia. These SPARQL queries will be shown here."
104     :vertical-scroll t
105     :create-callback #'(lambda (&rest x)
106                          (declare (ignore x))
107                          (setf (capi:editor-pane-text text-pane2) *pane2-message*))
108     :max-width *width*
109     :width *best-width*
110     :horizontal-scroll t)
111 
112    (text-pane3
113     capi:collector-pane ;; capi:display-pane ;; capi:text-input-pane
114     :text *pane3-message*
115     :font "Courier"
116     :line-wrap-marker nil
117     :wrap-style :split-on-space
118     :vertical-scroll :with-bar
119     :title "Results"
120     :horizontal-scroll t
121     :min-height 220
122     :width *best-width*
123     :create-callback #'(lambda (&rest x)
124                          (declare (ignore x))
125                          (setf (capi:editor-pane-text text-pane3) *pane3-message*))
126     :max-height 240
127     :max-width *width*)
128    (info
129     capi:title-pane
130     :text "Use natural language queries to generate SPARQL"))
131   (:layouts
132    (main-layout
133     capi:grid-layout
134     '(nil info
135       nil text-pane1
136       nil text-pane2
137       nil text-pane3)
138      :x-ratios '(1 99)
139     :has-title-column-p t))
140   (:default-initargs
141    :layout 'main-layout
142    :title "Knowledge Graph Navigator"
143    :best-width *best-width*
144    :max-width *width*))
145 
146 (defun start-background-thread (query-text self)
147   (format t "~%** ** entering start-progress-bar-test-from-background-thread:~%~%self=~S~%~%" self)
148   (with-slots (text-pane2 text-pane3) self
149     (print text-pane2)
150     (mp:process-run-function "progress-bar-test-from-background-thread"
151                               '()
152                               'run-and-monitor-progress-background-thread
153                               query-text text-pane2 text-pane3)))
154 
155 ;; This function runs in a separate thread.
156 
157 (defun run-and-monitor-progress-background-thread (text text-pane2 text-pane3)
158   (progn
159     (setf (capi:editor-pane-text text-pane2) "")
160     (setf (capi:editor-pane-text text-pane3) "")
161     ;;(capi:display-message "done")
162     (let ((message-stream (capi:collector-pane-stream text-pane2))
163           (results-stream (capi:collector-pane-stream text-pane3)))
164       (format message-stream "# Starting to process query....~%")
165       (format results-stream *pane3-message*)
166       (let ((user-selections (prompt-selection-list (get-entity-data-helper text :message-stream message-stream))))
167         (print "***** from prompt selection list:") (print user-selections)
168         (setf (capi:editor-pane-text text-pane3) "")
169         (dolist (ev user-selections)
170           (if (> (length (cadr ev)) 0)
171               (let ()
172                 (terpri results-stream)
173                 (capi::write-string-with-properties
174                  (format nil "- - - ENTITY TYPE: ~A - - -" (car ev))
175                  '(:highlight :compiler-error-highlight) results-stream)
176                 ;;(terpri results-stream)
177                 (dolist (uri (cadr ev))
178                   (setf uri (car uri))
179                   (case (car ev)
180                     (:people
181                      (pprint-results 
182                       (kgn-common:dbpedia-get-person-detail  uri :message-stream message-stream :colorize-sparql-function #'colorize-sparql)
183                       :stream results-stream))
184                     (:companies
185                      (pprint-results 
186                       (kgn-common:dbpedia-get-company-detail uri :message-stream message-stream :colorize-sparql-function #'colorize-sparql)
187                       :stream results-stream))
188                     (:countries
189                      (pprint-results
190                       (kgn-common:dbpedia-get-country-detail uri :message-stream message-stream :colorize-sparql-function #'colorize-sparql)
191                       :stream results-stream))
192                     (:cities
193                      (pprint-results
194                       (kgn-common:dbpedia-get-city-detail    uri :message-stream message-stream :colorize-sparql-function #'colorize-sparql)
195                       :stream results-stream))
196                     (:products
197                      (pprint-results
198                       (kgn-common:dbpedia-get-product-detail uri :message-stream message-stream :colorize-sparql-function #'colorize-sparql)
199                       :stream results-stream)))))))
200 
201         (let (links x)
202           (dolist (ev user-selections)
203             (dolist (uri (second ev))
204               (setf uri (car uri))
205               (if (> (length ev) 2)
206                   (setf x (caddr ev)))
207               (setf links (cons (list (symbol-name (first ev)) uri x) links)))
208 
209           (setf
210            links
211            (append
212             links
213             (entity-results->relationship-links
214              user-selections
215              :message-stream message-stream))))
216 
217           (if
218               *show-info-pane*
219               (lw-grapher:make-info-panel-grapher '("PEOPLE" "COMPANIES" "COUNTRIES" "CITIES" "PRODUCTS" "PLACES")
220                                                   links 'test-callback-click 'test-callback-click-shift))) ;; do  not use #' !!
221         (terpri results-stream)
222         (princ "** Done wih query **" results-stream)))))
223 
224    
225 
226 ;; MAIN entry point for application:
227 
228 (defun kgn-capi-ui ()
229   ;;(ignore-errors (create-dbpedia))
230   (capi:display (make-instance 'kgn-interface)))

The following diagram shows the high-level architecture of the Knowledge Graph Navigator CAPI UI developed in this chapter:

Architecture diagram

User Interface Utilites File user-interface.lisp

In the previous chapter, the function prompt-selection-list was defined in the file kgn-text-ui/kgn-text-ui.lisp for text based (console) UIs. Here it is implemented in a separate file user-interface.lisp in the project directory kgn-capi-ui.

 1 (in-package #:kgn-capi-ui)
 2 
 3 ;; (use-package "CAPI")
 4 
 5 (defun prompt-selection-list (a-list-of-choices) 
 6   (let (ret)
 7     (dolist (choice a-list-of-choices)
 8       (setf choice (remove-if #'null choice))
 9       (let* ((topic-type (car choice))
10              (choice-list-full (rest choice))
11              (choice-list (remove-duplicates
12                            (map 'list #'(lambda (z)
13                                           (list
14                                            z ;; (first z)
15                                            (string-shorten
16                                             (kgn-common:clean-comment
17                                              (kgn-common:clean-comment (cadr z)))
18                                             140 :first-remove-stop-words t)))
19                                 (apply #'append choice-list-full))
20                            :test #'equal)))
21         (let ((dialog-results (alexandria:flatten
22                                (capi:prompt-with-list ;; SHOW SELECTION LIST
23                                    (map 'list #'second choice-list)
24                                    (symbol-name topic-type)
25                                    :interaction :multiple-selection
26                                    :choice-class 'capi:button-panel
27                                    :pane-args '(:visible-min-width 910
28                                    :layout-class capi:column-layout))))
29               (ret2))
30           (dolist (x choice-list)
31             (if (find (second x) dialog-results)
32                 (setf ret2 (cons (car x) ret2))))
33           (if (> (length ret2) 0)
34               (setf ret (cons (list topic-type (reverse ret2)) ret))))))
35     (reverse ret)))
36 
37 ;; (get-entity-data-helper "Bill Gates went to Seattle to Microsoft")
38 ;; (prompt-selection-list
39 ;;   (get-entity-data-helper
40 ;;     "Bill Gates went to Seattle to Microsoft"))

User Interface CAPI Options Panes Definition File option-pane.lisp

In the following listing we define functions to implement CAPI menus:

 1 (in-package #:kgn-capi-ui)
 2 
 3 ;; options for:
 4 ;;  1. programming language to generate code snippets for
 5 ;;  2. colorization options (do we really need this??)
 6 ;;  3. show disk space used by caching
 7 ;;  4. option to remove local disk cache
 8 
 9 (defvar *width-options-panel* 800)
10 
11 ;; The SPARQL query cache is the SQLite file written by the sparql-cache package.
12 (defun get-cache-disk-space ()
13   (let ((x (ignore-errors (floor (with-open-file (file sparql-cache::*db-path*) (file-length file))
14                                  1000))))
15     (or x  0))) ;; units in kilobytes
16 
17 (defun clear-cache-callback (&rest val)
18   (declare (ignore val))
19   (ignore-errors (delete-file sparql-cache::*db-path*)))
20 
21 (defvar *code-snippet-language* nil)
22 (defun set-prog-lang (&rest val)
23   (format t "* set-prog-lang: val=~S~%" val)
24   (setf *code-snippet-language* (first val)))
25 
26 (capi:define-interface options-panel-interface ()
27   ()
28   (:panes
29    #|
30    (prog-lang-pane
31     capi:option-pane
32     :items '("No language set" "Python" "Common Lisp")
33     :visible-items-count 6
34     :selection (if (equal *code-snippet-language* nil)
35                    0
36                  (if (equal *code-snippet-language* "No language set")
37                      0
38                    (if (equal *code-snippet-language* "Python")
39                        1
40                      (if (equal *code-snippet-language* "Common Lisp")
41                          2
42                        0))))
43     :interaction :single-selection
44     :selection-callback
45     'set-prog-lang)|#
46    (disk-space-pane
47      capi:text-input-pane
48     :text (format nil "~A (kilobytes)" (get-cache-disk-space))
49     :title "Current size of cache:"
50     :min-width 170
51     :max-width *width-options-panel*)
52    (clear-disk-cache-pane
53     capi:push-button-panel
54     ;;:title "Clear local query cache:"
55     :items 
56     '("Clear local query cache")
57     :selection-callback 
58     #'(lambda (&rest val)
59         (declare (ignore val))
60         (clear-cache-callback)
61         ;; sparql-cache creates its tables when loaded, so recreate them:
62         (ignore-errors (sparql-cache::create-dbpedia))
63         (ignore-errors (sparql-cache::create-wikidata))
64         (ignore-errors (setf (capi:text-input-pane-text disk-space-pane)
65                              "0 (kilobytes)"))))
66    (toggle-graph-display
67     capi:option-pane
68     :items '("Show Graph Info Pane Browser" "Hide Graph Info Pane Browser")
69     :selected-item (if *show-info-pane* 0 1)
70     ;;:title ""
71     :selection-callback 'toggle-grapher-visibility))
72 
73   (:layouts
74    (main-layout
75     capi:grid-layout
76     '(nil disk-space-pane
77     nil clear-disk-cache-pane)
78     :x-ratios '(1 99)
79     :has-title-column-p nil))
80   (:default-initargs
81    :layout 'main-layout
82    :title "Knowledge Graph Navigator Options"
83    :max-width *width-options-panel*))
84 
85 ;; MAIN entry point for application:
86 
87 
88 ;;         (capi:display (make-instance 'options-panel-interface))
89 
90 (defun ui2 () (capi:display (make-instance 'options-panel-interface)))

The popup list in the last example looks like:

Popup list shows the user possible entity resolutions for each entity found in the input query. The user selects the resolved entities to use.

In this example there were two “Bill Gates” entities, one an early American frontiersman, the other the founder of Microsoft and I chose the latter person to continue finding information about.

Using LispWorks CAPI UI Toolkit

You can use the free LispWorks Personal Edition for running KGN. Using other Common Lisp implementations like Clozure-CL and SBCL will not work because the CAPI user interface library is proprietary to LispWorks. I would like to direct you to three online resources for learning CAPI:

I am not going to spend too much time in this chapter explaining my CAPI-based code. If you use LispWorks (either the free Personal or the Professional editions) you are likely to use CAPI and spending time on the official documentation and especially the included example programs is strongly recommended.

In the next section I will review the KGN specific application parts of the CAPI-based UI.

The following figure shows a popup window displaying a graph of discovered entities and relationships:

UI for info-pane-grapher

Since I just showed the info-pane-grapher this is a good time to digress to its implementation. This is in a different package and you will find the source code in src/lw-grapher/info-pane-grapher.lisp. I used the graph layout algorithm from ISI-Grapher Manual (by Gabriel Robbins). The function make-grapher in src/lw-grapher/grapher.lisp also displays a graph, without mouse support and an attached information pane. It is not used here but you might prefer it for reuse in your projects if you don’t need mouse interactions.

The graph nodes are derived from the class capi:pinboard-object:

1 (defclass text-node (capi:pinboard-object)
2   ((text :initarg :text :reader text-node-text)
3    (full-text :initarg :full-text :reader text-node-full-text)
4    (string-x-offset :accessor text-node-string-x-offset)
5    (string-y-offset :accessor text-node-string-y-offset)))

I customized how my graph nodes are drawn in a graph pane (this is derived from LispWorks example code):

 1 (defmethod capi:draw-pinboard-object (pinboard (self text-node)
 2                                                &key &allow-other-keys)
 3   (multiple-value-bind (X Y  width height)
 4       (capi:static-layout-child-geometry self)
 5     (let* ((half-width  (floor (1- width)  2))
 6            (half-height (floor (1- height) 2))
 7            (circle-x (+ X half-width))
 8            (circle-y (+ Y half-height))
 9            (background :white)
10            (foreground (if background
11                            :black
12                          (capi:simple-pane-foreground pinboard)))
13            (text (text-node-text self)))
14         (gp:draw-ellipse pinboard
15                            circle-x circle-y
16                            half-width half-height
17                            :filled t
18                            :foreground background)
19         (gp:draw-ellipse pinboard
20                          circle-x circle-y
21                          half-width half-height
22                          :foreground foreground)
23         (gp:draw-string pinboard
24                         text
25                         (+ X (text-node-string-x-offset self))
26                         (+ Y (text-node-string-y-offset self))
27                         :foreground foreground))))

Most of the work is done in the graph layout method that uses Gabriel Robbins’ algorithm. Here I just show the signature and we won’t go into implementation. If you are interested in modifying the layout code, I include a screen shot from ISI-Grapher manual showing the algorithm in a single page; see the file src/lw-grapher/Algorithm from ISI-Grapher Manual.png.

The following code snippets show the method signature for the layout algorithm function in the file src/lw-grapher/grapher.lisp. I also include the call to capi:graph-pane-nodes that is the CLOS reader method for getting the list of node objects in a graph pane:

1 (defun graph-layout (self &key force)
2   (declare (ignore force))
3   (let* ((nodes (capi:graph-pane-nodes self))
4     ...

The layout works in two passes. The first pass assigns each node a y position: a leaf node goes below the last leaf placed, and a parent node goes at the average y position of its children. The second pass assigns each node an x position to the right of its parents. Both passes recurse through the graph, and the relationship links that KGN discovers often form cycles (entity A links to B and B links back to A). The layout code records the nodes whose layout is in progress in hash tables and skips them, so a cycle can not cause infinite recursion.

The CAPI graph node model uses a function that is passed a node object and returns a list of this node’s child node objects. There are several examples of this in the CAPI graph examples that are included with LispWorks (see the CAPI documentation).

In src/lw-grapher/info-pane-grapher.lisp I wrote a function that builds a graph layout and instead of passing in a “return children” function I found it more convenient to wrap this process, accepting a list of graph nodes and graph edges as function arguments:

  1 (in-package :lw-grapher)
  2 
  3 ;; A Grapher (using the layout algorithm from the ISI-Grapher
  4 ;; user guide) with an info panel
  5 
  6 (defun make-info-panel-grapher (h-root-name-list h-edge-list
  7                                 h-callback-function-click
  8                                 h-callback-function-shift-click)
  9   (let (edges roots last-selected-node node-callback-click
 10       node-callback-click-shift output-pane)
 11     (labels
 12         ((handle-mouse-click-on-pane (pane x y)
 13            (ignore-errors
 14              (let ((object (capi:pinboard-object-at-position pane x y)))
 15                (if object
 16                    (let ()
 17                      (if last-selected-node
 18                          (capi:unhighlight-pinboard-object pane
 19                              last-selected-node))
 20                      (setf last-selected-node object)
 21                      (capi:highlight-pinboard-object pane object)
 22                      (let ((c-stream (collector-pane-stream output-pane))) 
 23                        (write-string
 24                          (funcall node-callback-click
 25                            (text-node-full-text object))
 26                          c-stream)
 27                        (terpri c-stream)))))))
 28          (handle-mouse-click-shift-on-pane (pane x y)
 29            (ignore-errors
 30              (let ((object
 31                     (capi:pinboard-object-at-position pane x y)))
 32                (if object
 33                    (let ()
 34                      (if last-selected-node
 35                          (capi:unhighlight-pinboard-object
 36                            pane last-selected-node))
 37                      (setf last-selected-node object)
 38                      (capi:highlight-pinboard-object pane object)
 39                      (let ((c-stream
 40                              (collector-pane-stream output-pane)))
 41                        (write-string
 42                          (funcall node-callback-click-shift
 43                            (text-node-full-text object))
 44                          c-stream)
 45                        (terpri c-stream)))))))
 46          
 47          (info-panel-node-children-helper (node-text)
 48            (let (ret)
 49              (dolist (e edges)
 50                (if (equal (first e) node-text)
 51                    (setf ret (cons (second e) ret))))
 52              (reverse ret)))
 53          
 54          (make-info-panel-grapher-helper
 55            (root-name-list edge-list callback-function-click
 56             callback-function-click-shift)
 57            ;; example: root-name-list: '("n1") edge-list:
 58            ;;   '(("n1" "n2") ("n1" "n3"))
 59            (setf edges edge-list
 60                  roots root-name-list
 61                  node-callback-click callback-function-click
 62                  node-callback-click-shift callback-function-click-shift)
 63            (capi:contain 
 64 
 65             (make-instance
 66              'column-layout
 67              :title "Entity Browser"
 68              :description
 69              (list
 70               (make-instance 'capi:graph-pane
 71                              :min-height 330
 72                              :max-height 420
 73                              :roots roots
 74                              :layout-function 'graph-layout
 75                              :children-function #'info-panel-node-children-helper
 76                              :edge-pane-function 
 77                              #'(lambda(self from to)
 78                                  (declare (ignore self))
 79                                  (let ((prop-name ""))
 80                                    (dolist (edge edge-list)
 81                                      (if (and
 82                                           (equal from (first edge))
 83                                           (equal to (second edge)))
 84                                          (if (and (> (length edge) 2) (third edge))
 85                                              (let ((last-index
 86                                                      (search
 87                                                       "/" (third edge) 
 88                                                       :from-end t)))
 89                                                (if last-index
 90                                                    (setf prop-name 
 91                                                     (subseq (third edge) 
 92                                                     (1+ last-index)))
 93                                                  (setf prop-name (third edge)))))))
 94                                    (make-instance 
 95                                     'capi:labelled-arrow-pinboard-object
 96                                     :data (format nil "~A" prop-name))))
 97                              :node-pinboard-class 'text-node
 98                              :input-model `(((:button-1 :release)
 99                                              ,#'(lambda (pane x y)
100                                                   (handle-mouse-click-on-pane 
101                                                     pane x y)))
102                                             ((:button-1 :release :shift) ;; :press)
103                                              ,#'(lambda (pane x y)
104                                                   (handle-mouse-click-shift-on-pane 
105                                                     pane x y))))
106                              :node-pane-function 'make-text-node)
107               (setf
108                output-pane
109                (make-instance 'capi:collector-pane
110                               :min-height 130
111                               :max-height 220
112                               :title "Message collection pane"
113                               :text "..."
114                               :vertical-scroll t
115                               :horizontal-scroll t))))
116             :title 
117     "Info Pane Browser: mouse click for info, mouse click + shift for web browser"
118             
119             :best-width 550 :best-height 450)))
120       (make-info-panel-grapher-helper h-root-name-list
121         h-edge-list h-callback-function-click
122         h-callback-function-shift-click))))

The two mouse click handlers print the string that the callback returns with write-string, not format. The node text is often a URI, and a ~ character in a URI would make format treat the text as format directives.

Wrap-up

This is a long example application for a book so I did not discuss all of the code in the project. If you enjoy running and experimenting with this example and want to modify it for your own projects then I hope that I provided a sufficient road map for you to do so.

I got the idea for the KGN application because I was spending quite a bit of time manually setting up SPARQL queries for DBPedia (and other public sources like WikiData) and I wanted to experiment with partially automating this process. I wrote the CAPI user interface for fun since this example application could have had similar functionality as a command line tool.