diff --git a/src/main/java/org/corpus_tools/annis/gui/graphml/DocumentGraphMapper.java b/src/main/java/org/corpus_tools/annis/gui/graphml/DocumentGraphMapper.java index 87c9859d8a..13cd8374df 100644 --- a/src/main/java/org/corpus_tools/annis/gui/graphml/DocumentGraphMapper.java +++ b/src/main/java/org/corpus_tools/annis/gui/graphml/DocumentGraphMapper.java @@ -4,18 +4,25 @@ import com.google.common.collect.HashMultimap; import com.google.common.collect.Multimap; import com.google.common.collect.Range; +import com.google.common.collect.TreeMultimap; import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; +import java.util.Deque; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Pattern; import java.util.stream.Collectors; +import javax.annotation.CheckForNull; import javax.xml.namespace.QName; import javax.xml.stream.XMLEventReader; import javax.xml.stream.XMLStreamException; @@ -34,6 +41,8 @@ import org.corpus_tools.salt.common.SSpan; import org.corpus_tools.salt.common.STextualDS; import org.corpus_tools.salt.common.STextualRelation; +import org.corpus_tools.salt.common.STimeline; +import org.corpus_tools.salt.common.STimelineRelation; import org.corpus_tools.salt.common.SToken; import org.corpus_tools.salt.core.GraphTraverseHandler; import org.corpus_tools.salt.core.SAnnotation; @@ -58,7 +67,11 @@ public class DocumentGraphMapper extends AbstractGraphMLMapper { private final Set hasOutgoingCoverageEdge; private final Set hasOutgoingDominanceEdge; private final Set> hasNonEmptyDominanceEdge; + private final Map gapEdges; private final Multimap isPartOf; + private Optional timeline; + private final Map timelineIndex; + private final Multimap coveredTlis; protected DocumentGraphMapper() { @@ -66,7 +79,11 @@ protected DocumentGraphMapper() { this.hasOutgoingCoverageEdge = new HashSet<>(); this.hasOutgoingDominanceEdge = new HashSet<>(); this.hasNonEmptyDominanceEdge = new HashSet<>(); + this.gapEdges = new HashMap<>(); this.isPartOf = HashMultimap.create(); + this.timeline = Optional.empty(); + this.timelineIndex = new HashMap<>(); + this.coveredTlis = HashMultimap.create(); } @@ -76,36 +93,205 @@ public static SDocumentGraph map(File inputFile) throws IOException, XMLStreamEx return mapper.graph; } + private static boolean isConnected(String a, String b, Multimap outgoingEdges) { + + HashSet visited = new HashSet<>(); + Deque stack = new LinkedList<>(); + stack.add(a); + + while (!stack.isEmpty()) { + String current = stack.removeLast(); + if (visited.add(current)) { + for (String out : outgoingEdges.get(current)) { + if (Objects.equal(b, out)) { + return true; + } + stack.add(out); + } + } + } + + return false; + } + @Override protected void firstPass(XMLEventReader reader) throws XMLStreamException { + Map keys = new TreeMap<>(); + int level = 0; + boolean inGraph = false; + Optional currentNodeId = Optional.empty(); + Optional currentDataKey = Optional.empty(); + Optional currentSourceId = Optional.empty(); + Optional currentTargetId = Optional.empty(); + Optional currentComponent = Optional.empty(); + + Map data = new HashMap<>(); + + Multimap tokenIdByComponentName = TreeMultimap.create(); + Map tokenToValue = new HashMap<>(); + Multimap outgoingOrderingEdges = HashMultimap.create(); + while (reader.hasNext()) { XMLEvent event = reader.nextEvent(); - if (event.isStartElement()) { - StartElement element = event.asStartElement(); - if ("edge".equals(element.getName().getLocalPart())) { - Attribute source = element.getAttributeByName(new QName("source")); - Attribute target = element.getAttributeByName(new QName("target")); - Attribute label = element.getAttributeByName(new QName("label")); - if (label != null) { - Component c = parseComponent(label.getValue()); - if (source != null) { - if (c.getType() == AnnotationComponentType.COVERAGE) { - hasOutgoingCoverageEdge.add(source.getValue()); - } else if (c.getType() == AnnotationComponentType.DOMINANCE) { - hasOutgoingDominanceEdge.add(source.getValue()); - } else if (c.getType() == AnnotationComponentType.PARTOF) { - isPartOf.put(Helper.addSaltPrefix(source.getValue()), - Helper.addSaltPrefix(target.getValue())); + switch (event.getEventType()) { + case XMLEvent.START_ELEMENT: + level++; + StartElement startElement = event.asStartElement(); + // create all new nodes + switch (startElement.getName().getLocalPart()) { + case "graph": + if (level == 2) { + inGraph = true; + } + break; + case "key": + if (level == 2) { + addAnnotationKey(keys, startElement); + } + break; + case "node": + if (inGraph && level == 3) { + Attribute id = startElement.getAttributeByName(new QName("id")); + if (id != null) { + currentNodeId = Optional.ofNullable(id.getValue()); + } + } + break; + case "edge": + if (inGraph && level == 3) { + // Get the source and target node IDs + Attribute source = startElement.getAttributeByName(new QName("source")); + Attribute target = startElement.getAttributeByName(new QName("target")); + Attribute label = startElement.getAttributeByName(new QName("label")); + Attribute component = startElement.getAttributeByName(new QName("label")); + if (label != null) { + Component c = parseComponent(label.getValue()); + if (source != null) { + if (c.getType() == AnnotationComponentType.COVERAGE) { + hasOutgoingCoverageEdge.add(source.getValue()); + } else if (c.getType() == AnnotationComponentType.DOMINANCE) { + hasOutgoingDominanceEdge.add(source.getValue()); + } else if (c.getType() == AnnotationComponentType.PARTOF) { + isPartOf.put(Helper.addSaltPrefix(source.getValue()), + Helper.addSaltPrefix(target.getValue())); + } else if (c.getType() == AnnotationComponentType.ORDERING + && "annis".equals(c.getLayer()) + && "".equals(c.getName()) && target != null) { + outgoingOrderingEdges.put(source.getValue(), target.getValue()); + } + if (target != null && c.getType() == AnnotationComponentType.DOMINANCE + && !c.getName().isEmpty()) { + hasNonEmptyDominanceEdge.add(Pair.of(source.getValue(), target.getValue())); + } + } + } + if (source != null && target != null && component != null) { + currentSourceId = Optional.ofNullable(source.getValue()); + currentTargetId = Optional.ofNullable(target.getValue()); + currentComponent = Optional.ofNullable(component.getValue()); + } } - if (target != null && c.getType() == AnnotationComponentType.DOMINANCE - && !c.getName().isEmpty()) { - hasNonEmptyDominanceEdge.add(Pair.of(source.getValue(), target.getValue())); + break; + case "data": + Attribute key = startElement.getAttributeByName(new QName("key")); + if (key != null) { + currentDataKey = Optional.ofNullable(key.getValue()); } + break; + } + break; + case XMLEvent.CHARACTERS: + if (currentDataKey.isPresent() && inGraph && level == 4) { + String annoKey = keys.get(currentDataKey.get()); + if (annoKey != null) { + // Copy all data attributes into our own map + data.put(annoKey, event.asCharacters().getData()); } } + break; + case XMLEvent.END_ELEMENT: + EndElement endElement = event.asEndElement(); + switch (endElement.getName().getLocalPart()) { + case "graph": + inGraph = false; + break; + case "node": + String tokValue = data.get("annis::tok"); + if (tokValue != null && currentNodeId.isPresent()) { + tokenToValue.put(tokValue, currentNodeId.get()); + } + + currentNodeId = Optional.empty(); + data.clear(); + break; + case "edge": + if (currentComponent.isPresent() && currentSourceId.isPresent() + && currentTargetId.isPresent()) { + Component component = parseComponent(currentComponent.get()); + if (component.getType() == AnnotationComponentType.ORDERING) { + tokenIdByComponentName.put(component.getName(), currentSourceId.get()); + tokenIdByComponentName.put(component.getName(), currentTargetId.get()); + } + } + + currentSourceId = Optional.empty(); + currentTargetId = Optional.empty(); + currentComponent = Optional.empty(); + data.clear(); + break; + case "data": + if (currentDataKey.isPresent()) { + String annoKey = keys.get(currentDataKey.get()); + // Add an empty data entry if this element did not have any character child + if (annoKey != null && !data.containsKey(annoKey)) { + data.put(annoKey, ""); + } + } + currentDataKey = Optional.empty(); + break; + } + + level--; + break; + } + } + + // Check if this GraphML file has a timeline. + if (tokenIdByComponentName.keySet().size() > 1) { + boolean hasNonEmptyBaseToken = false; + Pattern whitespacePattern = Pattern.compile("\\s*"); + + for (String tokId : tokenIdByComponentName.get("")) { + String tokValue = tokenToValue.getOrDefault(tokId, ""); + // Check if this base token value is non-empty + if (!whitespacePattern.matcher(tokValue).matches()) { + hasNonEmptyBaseToken = true; + break; + } + } + + if (!hasNonEmptyBaseToken) { + STimeline timeline = graph.createTimeline(); + + // Order the timeline tokens by the ordering edges + ArrayList tlis = new ArrayList<>(tokenIdByComponentName.get("")); + tlis.sort((a, b) -> { + if (isConnected(a, b, outgoingOrderingEdges)) { + return -1; + } else if (isConnected(b, a, outgoingOrderingEdges)) { + return 1; + } else { + return 0; + } + }); + timeline.setData(tlis.size()); + for (int i = 0; i < tlis.size(); i++) { + this.timelineIndex.put(tlis.get(i), i); } + this.timeline = Optional.of(timeline); } } + } @Override @@ -121,7 +307,6 @@ protected void secondPass(XMLEventReader reader) throws XMLStreamException { Optional currentComponent = Optional.empty(); SortedMap datasourcesInGraphMl = new TreeMap<>(); - Map gapEdges = new HashMap<>(); Map data = new HashMap<>(); @@ -193,7 +378,9 @@ protected void secondPass(XMLEventReader reader) throws XMLStreamException { if ("node".equals(nodeType)) { // Map node and add it SNode n = mapNode(currentNodeId.get(), data); - graph.addNode(n); + if (n != null) { + graph.addNode(n); + } } else if ("datasource".equals(nodeType)) { // Create a textual datasource of this name STextualDS ds = SaltFactory.createSTextualDS(); @@ -211,7 +398,7 @@ protected void secondPass(XMLEventReader reader) throws XMLStreamException { if (currentSourceId.isPresent() && currentTargetId.isPresent() && currentComponent.isPresent()) { mapAndAddEdge(currentSourceId.get(), currentTargetId.get(), currentComponent.get(), - data, gapEdges); + data); } currentSourceId = Optional.empty(); @@ -236,9 +423,26 @@ protected void secondPass(XMLEventReader reader) throws XMLStreamException { } } + if (timeline.isPresent()) { + // Add a timeline relation to all token + for (Map.Entry> entry : this.coveredTlis.asMap().entrySet()) { + + TreeSet sortedTlis = new TreeSet<>(entry.getValue()); + + STimelineRelation timeRel = SaltFactory.createSTimelineRelation(); + timeRel.setSource(entry.getKey()); + timeRel.setTarget(this.timeline.get()); + timeRel.setStart(sortedTlis.first()); + timeRel.setEnd(sortedTlis.last() + 1); + graph.addRelation(timeRel); + + } + + } + // Always create own own data sources from the tokens. Get all real token roots (ignore gaps) // and create a data source for each of them. - recreateTextForTokenRoots(graph, gapEdges, datasourcesInGraphMl); + recreateTextForTokenRoots(graph, datasourcesInGraphMl); // Create the text annotation for the segmentation nodes Multimap orderRoots = graph.getRootsByRelationType(SALT_TYPE.SORDER_RELATION); @@ -277,11 +481,30 @@ private void addAnnotationKey(Map keys, StartElement event) { } } + @CheckForNull private SNode mapNode(String nodeName, Map labels) { SNode newNode; - if ((labels.containsKey(ANNIS_TOK)) && !hasOutgoingCoverageEdge.contains(nodeName)) { - newNode = SaltFactory.createSToken(); + if ((labels.containsKey(ANNIS_TOK))) { + if (this.timeline.isPresent()) { + if (hasOutgoingCoverageEdge.contains(nodeName)) { + newNode = SaltFactory.createSToken(); + } else { + // Do not map timeline items as token + return null; + } + } else { + if (hasOutgoingCoverageEdge.contains(nodeName)) { + newNode = SaltFactory.createSSpan(); + } else { + newNode = SaltFactory.createSToken(); + } + } + if (!this.timeline.isPresent() && hasOutgoingCoverageEdge.contains(nodeName)) { + newNode = SaltFactory.createSSpan(); + } else { + newNode = SaltFactory.createSToken(); + } } else if (hasOutgoingDominanceEdge.contains(nodeName)) { newNode = SaltFactory.createSStructure(); } else { @@ -295,7 +518,7 @@ private SNode mapNode(String nodeName, Map labels) { } private void mapAndAddEdge(String sourceId, String targetId, String componentRaw, - Map labels, Map gapEdges) { + Map labels) { SNode source = graph.getNode(Helper.addSaltPrefix(sourceId)); SNode target = graph.getNode(Helper.addSaltPrefix(targetId)); @@ -303,9 +526,15 @@ private void mapAndAddEdge(String sourceId, String targetId, String componentRaw // Split the component description into its parts Component component = parseComponent(componentRaw); - if (source != null && target != null && source != target) { - + if (this.timeline.isPresent() && component.getType() == AnnotationComponentType.COVERAGE + && target == null + && source instanceof SToken) { + // The coverage edge describe to which timeline item the token belongs to. + // At this point, we are only interested in the index of the TLI. + this.coveredTlis.put((SToken) source, this.timelineIndex.get(targetId)); + } else if (source != null && target != null && source != target) { SRelation rel = null; + switch (component.getType()) { case DOMINANCE: if ((component.getName() == null || component.getName().isEmpty()) @@ -325,9 +554,14 @@ private void mapAndAddEdge(String sourceId, String targetId, String componentRaw if ("annis".equals(component.getLayer()) && "datasource-gap".equals(component.getName())) { if (source instanceof SToken && target instanceof SToken) { - gapEdges.put((SToken) source, (SToken) target); + this.gapEdges.put((SToken) source, (SToken) target); } - } else { + } else if (this.timeline.isPresent() && "annis".equals(component.getLayer()) + && "".equals(component.getName())) { + // Do not map ordering edges for the timeline + rel = null; + } + else { rel = graph.createRelation(source, target, SALT_TYPE.SORDER_RELATION, null); } @@ -352,14 +586,14 @@ private void mapAndAddEdge(String sourceId, String targetId, String componentRaw } } - private void recreateTextForTokenRoots(SDocumentGraph graph, Map gapEdges, + private void recreateTextForTokenRoots(SDocumentGraph graph, SortedMap datasourcesInGraphMl) { Map nextToken = new HashMap<>(); Map incomingOrderingEdgesWithGaphs = new HashMap<>(); for (SOrderRelation rel : graph.getOrderRelations()) { - if ((rel.getType() == null || "".equals(rel.getType())) && rel.getSource() instanceof SToken + if (rel.getSource() instanceof SToken && rel.getTarget() instanceof SToken) { SToken source = (SToken) rel.getSource(); SToken target = (SToken) rel.getTarget(); @@ -369,7 +603,7 @@ private void recreateTextForTokenRoots(SDocumentGraph graph, Map } } - for (Map.Entry rel : gapEdges.entrySet()) { + for (Map.Entry rel : this.gapEdges.entrySet()) { incomingOrderingEdgesWithGaphs.put(rel.getValue(), rel.getKey()); } @@ -385,13 +619,13 @@ private void recreateTextForTokenRoots(SDocumentGraph graph, Map // traverse the token chain using the order relations SToken currentToken = rootForText; while (currentToken != null) { - addToken(currentToken, text, token2Range); + addTokenToText(currentToken, text, token2Range); SToken previousToken = currentToken; currentToken = nextToken.get(previousToken); // Step over the possible gap if (currentToken == null) { - currentToken = gapEdges.get(previousToken); + currentToken = this.gapEdges.get(previousToken); } } @@ -400,6 +634,13 @@ private void recreateTextForTokenRoots(SDocumentGraph graph, Map if (datasourcesInGraphMl.size() == 1) { STextualDS origDs = datasourcesInGraphMl.get(datasourcesInGraphMl.firstKey()); ds.setName(origDs.getName()); + } else { + Optional orderingType = + rootForText.getOutRelations().stream().filter(rel -> rel instanceof SOrderRelation) + .map(rel -> (SOrderRelation) rel).map(rel -> rel.getType()).findFirst(); + if (orderingType.isPresent()) { + ds.setName(orderingType.get()); + } } // add all relations @@ -416,7 +657,8 @@ private void recreateTextForTokenRoots(SDocumentGraph graph, Map } - private void addToken(SToken token, StringBuilder text, Map> token2Range) { + private void addTokenToText(SToken token, StringBuilder text, + Map> token2Range) { SFeature featTokWhitespaceBefore = token.getFeature("annis::tok-whitespace-before"); if (featTokWhitespaceBefore != null) { text.append(featTokWhitespaceBefore.getValue().toString()); diff --git a/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/EventExtractor.java b/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/EventExtractor.java index 9119d21e38..65067adfc1 100644 --- a/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/EventExtractor.java +++ b/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/EventExtractor.java @@ -38,6 +38,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.corpus_tools.annis.gui.Helper; import org.corpus_tools.annis.gui.PDFPageHelper; @@ -50,6 +51,7 @@ import org.corpus_tools.salt.common.SSpan; import org.corpus_tools.salt.common.SSpanningRelation; import org.corpus_tools.salt.common.STextualDS; +import org.corpus_tools.salt.common.STimeline; import org.corpus_tools.salt.common.SToken; import org.corpus_tools.salt.core.SAnnotation; import org.corpus_tools.salt.core.SFeature; @@ -87,10 +89,18 @@ private static void addAnnotationsForNode(SNode node, SDocumentGraph graph, } // calculate the left and right values of a span - Range overlappedSpan = Helper.getLeftRightSpan(node, graph, token2index); - int left = overlappedSpan.lowerEndpoint(); + STimeline timeline = graph.getTimeline(); + Range overlappedSpan; + if (timeline != null) { + overlappedSpan = TimelineSpanCollector.getRange(graph, node); + } else { + // Use the token to get the node span + overlappedSpan = Helper.getLeftRightSpan(node, graph, token2index); + } + int left = overlappedSpan.lowerEndpoint(); int right = overlappedSpan.upperEndpoint(); + for (SAnnotation anno : node.getAnnotations()) { ArrayList rows = rowsByAnnotation.get(anno.getQName()); if (rows == null) { @@ -120,35 +130,46 @@ else if (matchedQualifiedAnnoName.equals(anno.getQName())) { } } - if (node instanceof SSpan) { - // calculate overlapped SToken - - List> outEdges = - graph.getOutRelations(node.getId()); - if (outEdges != null) { - for (SRelation e : outEdges) { - if (e instanceof SSpanningRelation) { - SSpanningRelation spanRel = (SSpanningRelation) e; - - SToken tok = spanRel.getTarget(); - event.getCoveredIDs().add(tok.getId()); - - // get the STextualDS of this token and add it to the event - String textID = Helper.getTextualDSForNode(tok, graph).getId(); - if (textID != null) { - event.setTextID(textID); + + if(timeline != null) { + for(Range range : TimelineSpanCollector.getAllRanges(graph, node)) { + for(int i = range.lowerEndpoint(); i <= range.upperEndpoint(); i++) { + event.getCoveredIDs().add("" + i); + + } + } + } else { + if (node instanceof SSpan) { + // calculate overlapped SToken + List> outEdges = + graph.getOutRelations(node.getId()); + if (outEdges != null) { + for (SRelation e : outEdges) { + if (e instanceof SSpanningRelation) { + SSpanningRelation spanRel = (SSpanningRelation) e; + + SToken tok = spanRel.getTarget(); + event.getCoveredIDs().add(tok.getId()); + + // get the STextualDS of this token and add it to the event + String textID = Helper.getTextualDSForNode(tok, graph).getId(); + if (textID != null) { + event.setTextID(textID); + } } } + } // end if span has out edges + } else if (node instanceof SToken) { + event.getCoveredIDs().add(node.getId()); + // get the STextualDS of this token and add it to the event + String textID = Helper.getTextualDSForNode(node, graph).getId(); + if (textID != null) { + event.setTextID(textID); } - } // end if span has out edges - } else if (node instanceof SToken) { - event.getCoveredIDs().add(node.getId()); - // get the STextualDS of this token and add it to the event - String textID = Helper.getTextualDSForNode(node, graph).getId(); - if (textID != null) { - event.setTextID(textID); - } + } } + + // try to get time annotations if (mediaLayer == null || mediaLayer.contains(anno.getQName())) { @@ -184,17 +205,6 @@ else if (matchedQualifiedAnnoName.equals(anno.getQName())) { } // end for each annotation of span } - private static long clip(long value, long min, long max) { - if (value > max) { - return max; - } else if (value < min) { - return min; - } else { - return value; - } - - } - /** * Returns the annotations to display according to the mappings configuration. * @@ -466,6 +476,7 @@ public static LinkedHashMap> parseSalt(VisualizerInput in PDFPageHelper pageNumberHelper = new PDFPageHelper(input); + if (showSpanAnnos) { for (SSpan span : graph.getSpans()) { if (text == null || text == Helper.getTextualDSForNode(span, graph)) { @@ -475,6 +486,7 @@ public static LinkedHashMap> parseSalt(VisualizerInput in } // end for each span } + if (showTokenAnnos) { for (SToken tok : graph.getTokens()) { if (text == null || text == Helper.getTextualDSForNode(tok, graph)) { @@ -510,6 +522,7 @@ public static LinkedHashMap> parseSalt(VisualizerInput in } } + return rowsByAnnotation; } @@ -617,58 +630,90 @@ private static void sortEventsByTokenIndex(Row row) { private static void splitRowsOnGaps(Row row, final SDocumentGraph graph, Map token2index) { ListIterator itEvents = row.getEvents().listIterator(); + STimeline timeline = graph.getTimeline(); while (itEvents.hasNext()) { GridEvent event = itEvents.next(); - int lastTokenIndex = -1; - - // sort the coveredIDs - LinkedList sortedCoveredToken = new LinkedList<>(event.getCoveredIDs()); - Collections.sort(sortedCoveredToken, (o1, o2) -> { - SToken node1 = (SToken) graph.getNode(o1); - SToken node2 = (SToken) graph.getNode(o2); + List gaps = new LinkedList<>(); + if (timeline != null) { + // Calculate the gaps using the covered timeline items + List coveredTlis = event.getCoveredIDs().stream().map(id -> Integer.parseInt(id)) + .sorted() + .collect(Collectors.toList()); + int lastTli = -1; + for (int tli : coveredTlis) { + + // sanity check + if (tli >= event.getLeft() && tli <= event.getRight()) { + int diff = tli - lastTli; + + if (lastTli >= 0 && diff > 1) { + // we detected a gap + GridEvent gap = new GridEvent(event.getId() + "_gap_" + gaps.size(), + lastTli + 1, tli - 1, ""); + gap.setGap(true); + gaps.add(gap); + } - if (node1 == node2) { - return 0; - } - if (node1 == null) { - return -1; - } - if (node2 == null) { - return +1; + lastTli = tli; + } else { + // reset gap search when discovered there were token we use for + // highlighting but do not actually cover + lastTli = -1; + } } - long tokenIndex1 = token2index.get(node1); - long tokenIndex2 = token2index.get(node2); + } else { + // Calculate the gaps using the covered token + int lastTokenIndex = -1; - return ((Long) (tokenIndex1)).compareTo(tokenIndex2); - }); + // sort the coveredIDs + LinkedList sortedCoveredToken = new LinkedList<>(event.getCoveredIDs()); + Collections.sort(sortedCoveredToken, (o1, o2) -> { + SToken node1 = (SToken) graph.getNode(o1); + SToken node2 = (SToken) graph.getNode(o2); - // first calculate all gaps - List gaps = new LinkedList<>(); - for (String id : sortedCoveredToken) { - SToken node = (SToken) graph.getNode(id); - int tokenIndex = token2index.get(node); - - // sanity check - if (tokenIndex >= event.getLeft() && tokenIndex <= event.getRight()) { - int diff = tokenIndex - lastTokenIndex; - - if (lastTokenIndex >= 0 && diff > 1) { - // we detected a gap - GridEvent gap = new GridEvent(event.getId() + "_gap_" + gaps.size(), lastTokenIndex + 1, - tokenIndex - 1, ""); - gap.setGap(true); - gaps.add(gap); + if (node1 == node2) { + return 0; + } + if (node1 == null) { + return -1; + } + if (node2 == null) { + return +1; } - lastTokenIndex = tokenIndex; - } else { - // reset gap search when discovered there were token we use for - // hightlighting but do not actually cover - lastTokenIndex = -1; - } - } // end for each covered token id + long tokenIndex1 = token2index.get(node1); + long tokenIndex2 = token2index.get(node2); + + return ((Long) (tokenIndex1)).compareTo(tokenIndex2); + }); + + // Actually calculate the gaps + for (String id : sortedCoveredToken) { + SToken node = (SToken) graph.getNode(id); + int tokenIndex = token2index.get(node); + + // sanity check + if (tokenIndex >= event.getLeft() && tokenIndex <= event.getRight()) { + int diff = tokenIndex - lastTokenIndex; + + if (lastTokenIndex >= 0 && diff > 1) { + // we detected a gap + GridEvent gap = new GridEvent(event.getId() + "_gap_" + gaps.size(), + lastTokenIndex + 1, tokenIndex - 1, ""); + gap.setGap(true); + gaps.add(gap); + } + + lastTokenIndex = tokenIndex; + } else { + // reset gap search when discovered there were token we use for + // highlighting but do not actually cover + lastTokenIndex = -1; + } + } // end for each covered token id + } ListIterator itGaps = gaps.listIterator(); // remember the old right value @@ -722,6 +767,7 @@ private static void splitRowsOnGaps(Row row, final SDocumentGraph graph, private static void splitRowsOnIslands(Row row, final SDocumentGraph graph, STextualDS text, Map token2index) { + STimeline timeline = graph.getTimeline(); BitSet tokenCoverage = new BitSet(); // get the sorted token List sortedTokenList = graph.getSortedTokenByText(); @@ -729,7 +775,13 @@ private static void splitRowsOnIslands(Row row, final SDocumentGraph graph, STex ListIterator itToken = sortedTokenList.listIterator(); while (itToken.hasNext()) { SToken t = itToken.next(); - if (text == null || text == Helper.getTextualDSForNode(t, graph)) { + if (timeline != null) { + Range coveredRange = TimelineSpanCollector.getRange(graph, t); + for (int i = coveredRange.lowerEndpoint(); i <= coveredRange.upperEndpoint(); i++) { + tokenCoverage.set(i); + } + } + else if (text == null || text == Helper.getTextualDSForNode(t, graph)) { int tokenIndex = token2index.get(t); tokenCoverage.set(tokenIndex); } diff --git a/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/GridVisualizer.java b/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/GridVisualizer.java index 3db59ccb5d..ebc80e008a 100644 --- a/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/GridVisualizer.java +++ b/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/GridVisualizer.java @@ -19,6 +19,7 @@ import org.corpus_tools.annis.gui.media.PDFController; import org.corpus_tools.annis.gui.visualizers.AbstractVisualizer; import org.corpus_tools.annis.gui.visualizers.VisualizerInput; +import org.corpus_tools.salt.common.SDocumentGraph; import org.corpus_tools.salt.common.STextualDS; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -64,9 +65,9 @@ public GridComponent createComponent(VisualizerInput visInput, VisualizationTogg PDFController pdfController = visInput.getUI().getSession().getAttribute(PDFController.class); GridComponent component = null; try { - - List texts = visInput.getDocument().getDocumentGraph().getTextualDSs(); - if (texts.size() == 1) { + SDocumentGraph documentGraph = visInput.getDocument().getDocumentGraph(); + List texts = documentGraph.getTextualDSs(); + if (texts.size() == 1 || documentGraph.getTimeline() != null) { component = new SingleGridComponent(visInput, mediaController, pdfController, true, null); } else { component = new MultipleGridComponent(visInput, mediaController, pdfController, true); diff --git a/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/SingleGridComponent.java b/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/SingleGridComponent.java index 61ac4fbb05..22c8d5d918 100644 --- a/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/SingleGridComponent.java +++ b/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/SingleGridComponent.java @@ -31,6 +31,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.regex.Pattern; import org.corpus_tools.annis.gui.AnnisUI; import org.corpus_tools.annis.gui.Helper; @@ -44,11 +45,13 @@ import org.corpus_tools.salt.common.SOrderRelation; import org.corpus_tools.salt.common.SSpan; import org.corpus_tools.salt.common.STextualDS; +import org.corpus_tools.salt.common.STimeline; import org.corpus_tools.salt.common.SToken; import org.corpus_tools.salt.core.SAnnotation; import org.corpus_tools.salt.core.SFeature; import org.corpus_tools.salt.core.SNode; import org.corpus_tools.salt.core.SRelation; +import org.corpus_tools.salt.util.DataSourceSequence; /** * @@ -227,6 +230,16 @@ && hasSegmentation(t, this.segmentationName)) { return tokenRow; } + + private Row computeTimelineRow(STimeline timeline) { + Row timelineRow = new Row(); + for (int i = timeline.getStart(); i < timeline.getEnd(); i++) { + GridEvent event = new GridEvent(timeline.getId() + "-" + i, i, i, "" + i); + timelineRow.addEvent(event); + } + + return timelineRow; + } private boolean createAnnotationGrid() { String resultID = input.getId(); @@ -303,8 +316,6 @@ private boolean createAnnotationGrid() { if (origValue.equals(targetValue)) { ev.setValue(unit_split[1]); } - // String newValue = unit_split[1].replaceAll("%%value%%",origValue); - } } @@ -314,47 +325,109 @@ private boolean createAnnotationGrid() { } } - // add tokens as row - Row tokenRow = computeTokenRow(sortedSegmentationNodes, graph, rowsByAnnotation, token2index); - - String tokenRowCaption = "tok"; - if (isHidingToken()) { + boolean tokenRowIsEmpty = true; + STimeline timeline = graph.getTimeline(); + if (timeline != null) { + Row timelineRow = computeTimelineRow(timeline); + timelineRow.setStyle("invisible_token"); - // We have to add the invisible token row avoid issues with the layout - // (see https://github.com/korpling/ANNIS/issues/524) - // but we don't want the invisible token layer to override an actual "tok" - // annotation layer (see https://github.com/korpling/ANNIS/issues/596) - tokenRow.setStyle("invisible_token"); - tokenRowCaption = ""; + tokenRowIsEmpty = false; grid.setTokRowKey(""); - } + rowsByAnnotation.put("", Lists.newArrayList(timelineRow)); + if (!isHidingToken()) { + TreeMap allTokenRows = new TreeMap<>(); + // also calculate tokens from *all* texts as rows and display them aligned by the timeline + for (STextualDS ds : graph.getTextualDSs()) { + Row tokenRow = new Row(); + + final DataSourceSequence seq = new DataSourceSequence<>(); + seq.setDataSource(ds); + seq.setStart(0); + seq.setEnd(ds.getText() != null ? ds.getText().length() : 0); + List tokensForDs = graph.getTokensBySequence(seq); + + if (tokensForDs != null) { + for (SToken t : tokensForDs) { + Range tokenRange = TimelineSpanCollector.getRange(graph, t); + GridEvent event = new GridEvent(t.getId(), tokenRange.lowerEndpoint(), + tokenRange.upperEndpoint(), graph.getText(t)); + event.setTextID(ds.getId()); + for (Range coveredRange : TimelineSpanCollector.getAllRanges(graph, t)) { + for (int i = coveredRange.lowerEndpoint(); i <= coveredRange.upperEndpoint(); i++) { + event.getCoveredIDs().add(timeline.getId() + "-" + i); + } + } + tokenRow.addEvent(event); + } + } + + allTokenRows.put(ds.getName(), tokenRow); + } - if (isTokenFirst()) { - // copy original list but add token row at the beginning - LinkedHashMap> newList = new LinkedHashMap<>(); + if (isTokenFirst()) { + // copy original list but add token row at the beginning + LinkedHashMap> newList = new LinkedHashMap<>(); + + for (Map.Entry entry : allTokenRows.entrySet()) { + newList.put(entry.getKey(), Lists.newArrayList(entry.getValue())); + } + newList.putAll(rowsByAnnotation); + rowsByAnnotation = newList; + } else { + for (Map.Entry entry : allTokenRows.entrySet()) { + rowsByAnnotation.put(entry.getKey(), Lists.newArrayList(entry.getValue())); + } + } + + for (Row tokenRow : allTokenRows.values()) { + EventExtractor.removeEmptySpace(rowsByAnnotation, tokenRow); + } + } - newList.put(tokenRowCaption, Lists.newArrayList(tokenRow)); - newList.putAll(rowsByAnnotation); - rowsByAnnotation = newList; } else { - // just add the token row to the end of the list - rowsByAnnotation.put(tokenRowCaption, Lists.newArrayList(tokenRow)); - } + // add tokens as row + Row tokenRow = computeTokenRow(sortedSegmentationNodes, graph, rowsByAnnotation, token2index); + + String tokenRowCaption = "tok"; + if (isHidingToken()) { + + // We have to add the invisible token row avoid issues with the layout + // (see https://github.com/korpling/ANNIS/issues/524) + // but we don't want the invisible token layer to override an actual "tok" + // annotation layer (see https://github.com/korpling/ANNIS/issues/596) + tokenRow.setStyle("invisible_token"); + tokenRowCaption = ""; + grid.setTokRowKey(""); + } - EventExtractor.removeEmptySpace(rowsByAnnotation, tokenRow); + if (isTokenFirst()) { + // copy original list but add token row at the beginning + LinkedHashMap> newList = new LinkedHashMap<>(); - // check if the token row only contains empty values - boolean tokenRowIsEmpty = true; - for (GridEvent tokenEvent : tokenRow.getEvents()) { - if (tokenEvent.getValue() != null && !tokenEvent.getValue().trim().isEmpty()) { - tokenRowIsEmpty = false; - break; + newList.put(tokenRowCaption, Lists.newArrayList(tokenRow)); + newList.putAll(rowsByAnnotation); + rowsByAnnotation = newList; + + } else { + // just add the token row to the end of the list + rowsByAnnotation.put(tokenRowCaption, Lists.newArrayList(tokenRow)); + } + + EventExtractor.removeEmptySpace(rowsByAnnotation, tokenRow); + + // check if the token row only contains empty values + for (GridEvent tokenEvent : tokenRow.getEvents()) { + if (tokenEvent.getValue() != null && !tokenEvent.getValue().trim().isEmpty()) { + tokenRowIsEmpty = false; + break; + } + } + if (!isHidingToken() && canShowEmptyTokenWarning()) { + lblEmptyToken.setVisible(tokenRowIsEmpty); } } - if (!isHidingToken() && canShowEmptyTokenWarning()) { - lblEmptyToken.setVisible(tokenRowIsEmpty); - } + grid.setRowsByAnnotation(rowsByAnnotation); return !tokenRowIsEmpty; diff --git a/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/TimelineSpanCollector.java b/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/TimelineSpanCollector.java new file mode 100644 index 0000000000..5def2166bb --- /dev/null +++ b/src/main/java/org/corpus_tools/annis/gui/visualizers/component/grid/TimelineSpanCollector.java @@ -0,0 +1,89 @@ +package org.corpus_tools.annis.gui.visualizers.component.grid; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Range; +import java.util.Arrays; +import java.util.HashSet; +import java.util.OptionalInt; +import java.util.Set; +import org.corpus_tools.salt.common.SDocumentGraph; +import org.corpus_tools.salt.common.STimeOverlappingRelation; +import org.corpus_tools.salt.common.STimeline; +import org.corpus_tools.salt.common.STimelineRelation; +import org.corpus_tools.salt.core.GraphTraverseHandler; +import org.corpus_tools.salt.core.SGraph.GRAPH_TRAVERSE_TYPE; +import org.corpus_tools.salt.core.SNode; +import org.corpus_tools.salt.core.SRelation; + +public class TimelineSpanCollector implements GraphTraverseHandler { + + + + public static Range getRange(SDocumentGraph graph, SNode node) { + STimeline timeline = graph.getTimeline(); + Preconditions.checkNotNull(timeline); + + TimelineSpanCollector collector = new TimelineSpanCollector(); + graph.traverse(Arrays.asList(node), GRAPH_TRAVERSE_TYPE.TOP_DOWN_DEPTH_FIRST, + "TimelineSpanCollector", collector); + + OptionalInt start = + collector.collectedRanges.stream().mapToInt((range) -> range.lowerEndpoint()).min(); + OptionalInt end = + collector.collectedRanges.stream().mapToInt((range) -> range.upperEndpoint()).max(); + + if (start.isPresent() && end.isPresent()) { + return Range.closed(start.getAsInt(), end.getAsInt()); + } else { + // Use the whole timeline as a fallback + return Range.closed(timeline.getStart(), timeline.getEnd() - 1); + } + } + + public static Set> getAllRanges(SDocumentGraph graph, SNode node) { + STimeline timeline = graph.getTimeline(); + Preconditions.checkNotNull(timeline); + + TimelineSpanCollector collector = new TimelineSpanCollector(); + graph.traverse(Arrays.asList(node), GRAPH_TRAVERSE_TYPE.TOP_DOWN_DEPTH_FIRST, + "TimelineSpanCollector", collector); + + return collector.collectedRanges; + + } + + private final HashSet> collectedRanges; + + private TimelineSpanCollector() { + collectedRanges = new HashSet<>(); + } + + @Override + public void nodeReached(GRAPH_TRAVERSE_TYPE traversalType, String traversalId, SNode currNode, + @SuppressWarnings("rawtypes") SRelation relation, SNode fromNode, long order) { + if (relation instanceof STimelineRelation) { + STimelineRelation timelineRelation = (STimelineRelation) relation; + this.collectedRanges + .add(Range.closed(timelineRelation.getStart(), timelineRelation.getEnd() - 1)); + } + + } + + @Override + public void nodeLeft(GRAPH_TRAVERSE_TYPE traversalType, String traversalId, SNode currNode, + SRelation relation, SNode fromNode, long order) { + + } + + @Override + public boolean checkConstraint(GRAPH_TRAVERSE_TYPE traversalType, String traversalId, + SRelation relation, SNode currNode, long order) { + if (relation == null) { + return true; + } else if (relation instanceof STimeOverlappingRelation) { + return true; + } + return false; + } + +}